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    /// Create or update an association.
220    pub fn put(&self, env: &Environment, assoc: &Association) -> Result<()> {
221        let key = assoc.encode_key();
222        let val = rmp_serde::to_vec(assoc)
223            .map_err(|e| CoreError::Memory(format!("serialize association: {e}")))?;
224
225        let mut tx = env
226            .begin_rw_txn()
227            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn: {e}")))?;
228        tx.put(self.db, &key, &val, WriteFlags::default())
229            .map_err(|e| CoreError::Memory(format!("LMDB put association: {e}")))?;
230        tx.commit()
231            .map_err(|e| CoreError::Memory(format!("LMDB commit: {e}")))?;
232        Ok(())
233    }
234
235    /// Get an association between two specific memories.
236    pub fn get(
237        &self,
238        env: &Environment,
239        source: Uuid,
240        target: Uuid,
241    ) -> Result<Option<Association>> {
242        let key = Association::encode_key_pair(source, target);
243        let tx = env
244            .begin_ro_txn()
245            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn: {e}")))?;
246        match tx.get(self.db, &key) {
247            Ok(bytes) => {
248                let assoc: Association = rmp_serde::from_slice(bytes)
249                    .map_err(|e| CoreError::Memory(format!("deserialize association: {e}")))?;
250                tx.commit()
251                    .map_err(|e| CoreError::Memory(format!("LMDB commit: {e}")))?;
252                Ok(Some(assoc))
253            }
254            Err(lmdb::Error::NotFound) => {
255                tx.commit()
256                    .map_err(|e| CoreError::Memory(format!("LMDB commit: {e}")))?;
257                Ok(None)
258            }
259            Err(e) => Err(CoreError::Memory(format!("LMDB get association: {e}"))),
260        }
261    }
262
263    /// Delete an association.
264    pub fn delete(&self, env: &Environment, source: Uuid, target: Uuid) -> Result<bool> {
265        let key = Association::encode_key_pair(source, target);
266        let mut tx = env
267            .begin_rw_txn()
268            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn: {e}")))?;
269        let exists = tx.get(self.db, &key).is_ok();
270        if exists {
271            tx.del(self.db, &key, None)
272                .map_err(|e| CoreError::Memory(format!("LMDB del association: {e}")))?;
273        }
274        tx.commit()
275            .map_err(|e| CoreError::Memory(format!("LMDB commit: {e}")))?;
276        Ok(exists)
277    }
278
279    /// Find all associations where the given UUID is the source.
280    pub fn find_from(&self, env: &Environment, source: Uuid) -> Result<Vec<Association>> {
281        let prefix = source.as_bytes();
282        let tx = env
283            .begin_ro_txn()
284            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn: {e}")))?;
285        let mut cursor = tx
286            .open_ro_cursor(self.db)
287            .map_err(|e| CoreError::Memory(format!("LMDB cursor: {e}")))?;
288
289        let mut results = Vec::new();
290        for (key, val) in cursor.iter() {
291            if key.len() >= 16 && &key[..16] == prefix {
292                if let Ok(assoc) = rmp_serde::from_slice::<Association>(val) {
293                    results.push(assoc);
294                }
295            }
296        }
297        drop(cursor);
298        tx.commit()
299            .map_err(|e| CoreError::Memory(format!("LMDB commit: {e}")))?;
300        Ok(results)
301    }
302
303    /// Find all associations where the given UUID is the target.
304    pub fn find_to(&self, env: &Environment, target: Uuid) -> Result<Vec<Association>> {
305        let suffix = target.as_bytes();
306        let tx = env
307            .begin_ro_txn()
308            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn: {e}")))?;
309        let mut cursor = tx
310            .open_ro_cursor(self.db)
311            .map_err(|e| CoreError::Memory(format!("LMDB cursor: {e}")))?;
312
313        let mut results = Vec::new();
314        for (key, val) in cursor.iter() {
315            if key.len() >= 32 && &key[16..32] == suffix {
316                if let Ok(assoc) = rmp_serde::from_slice::<Association>(val) {
317                    results.push(assoc);
318                }
319            }
320        }
321        drop(cursor);
322        tx.commit()
323            .map_err(|e| CoreError::Memory(format!("LMDB commit: {e}")))?;
324        Ok(results)
325    }
326
327    /// Traverse cross-galaxy associations starting from a seed memory ID (S9 traversal).
328    ///
329    /// Walks association edges (outgoing and incoming) with weight >= `min_weight`,
330    /// resolving each neighbor across galaxies via `store.find_across_galaxies()`.
331    pub fn traverse_cross_galaxy(
332        &self,
333        env: &Environment,
334        store: &crate::store::MemoryStore,
335        seed_id: Uuid,
336        min_weight: f32,
337    ) -> Result<Vec<(Association, Galaxy, crate::memory::Memory)>> {
338        let outgoing = self.find_from(env, seed_id)?;
339        let incoming = self.find_to(env, seed_id)?;
340        let mut results = Vec::new();
341
342        for edge in outgoing.into_iter().chain(incoming) {
343            if edge.weight < min_weight {
344                continue;
345            }
346            let neighbor_id = if edge.source == seed_id {
347                edge.target
348            } else {
349                edge.source
350            };
351            if neighbor_id == seed_id {
352                continue;
353            }
354            if let Some((galaxy, mem)) = store.find_across_galaxies(neighbor_id)? {
355                results.push((edge, galaxy, mem));
356            }
357        }
358
359        Ok(results)
360    }
361
362    /// Count all associations.
363    pub fn count(&self, env: &Environment) -> Result<usize> {
364        let tx = env
365            .begin_ro_txn()
366            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn: {e}")))?;
367        let mut cursor = tx
368            .open_ro_cursor(self.db)
369            .map_err(|e| CoreError::Memory(format!("LMDB cursor: {e}")))?;
370        let count = cursor.iter().count();
371        drop(cursor);
372        tx.commit()
373            .map_err(|e| CoreError::Memory(format!("LMDB commit: {e}")))?;
374        Ok(count)
375    }
376
377    /// Find all direct circular associations (A→B and B→A both exist).
378    ///
379    /// Circular links can artificially inflate importance in retention
380    /// calculations. This method scans all associations and returns pairs
381    /// of UUIDs where both directions exist, allowing the retention engine
382    /// to avoid double-counting.
383    pub fn find_cycles(&self, env: &Environment) -> Result<Vec<(Uuid, Uuid)>> {
384        let tx = env
385            .begin_ro_txn()
386            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn: {e}")))?;
387        let mut cursor = tx
388            .open_ro_cursor(self.db)
389            .map_err(|e| CoreError::Memory(format!("LMDB cursor: {e}")))?;
390
391        // Collect all (source, target) pairs
392        let mut pairs: Vec<(Uuid, Uuid)> = Vec::new();
393        let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
394        for (key, _val) in cursor.iter() {
395            if key.len() == 32 {
396                let mut arr = [0u8; 32];
397                arr.copy_from_slice(key);
398                if seen.insert(arr) {
399                    let src = Uuid::from_bytes(arr[..16].try_into().unwrap());
400                    let tgt = Uuid::from_bytes(arr[16..32].try_into().unwrap());
401                    pairs.push((src, tgt));
402                }
403            }
404        }
405        drop(cursor);
406        tx.commit()
407            .map_err(|e| CoreError::Memory(format!("LMDB commit: {e}")))?;
408
409        // Find cycles: for each (A, B), check if (B, A) also exists
410        let pair_set: std::collections::HashSet<[u8; 32]> = pairs
411            .iter()
412            .map(|(s, t)| {
413                let mut arr = [0u8; 32];
414                arr[..16].copy_from_slice(s.as_bytes());
415                arr[16..32].copy_from_slice(t.as_bytes());
416                arr
417            })
418            .collect();
419
420        let cycles: Vec<(Uuid, Uuid)> = pairs
421            .into_iter()
422            .filter(|(s, t)| {
423                // Check if reverse (t, s) exists, but avoid reporting both directions
424                let mut reverse = [0u8; 32];
425                reverse[..16].copy_from_slice(t.as_bytes());
426                reverse[16..32].copy_from_slice(s.as_bytes());
427                pair_set.contains(&reverse) && s < t // only report once per pair
428            })
429            .collect();
430
431        Ok(cycles)
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use tempfile::tempdir;
439
440    fn open_store() -> (tempfile::TempDir, Environment, AssociationStore) {
441        let tmp = tempdir().unwrap();
442        let env = Environment::new()
443            .set_map_size(1024 * 1024)
444            .set_max_dbs(16)
445            .open(tmp.path())
446            .unwrap();
447        let store = AssociationStore::open(&env).unwrap();
448        (tmp, env, store)
449    }
450
451    #[test]
452    fn put_and_get_association() {
453        let (_tmp, env, store) = open_store();
454        let src = Uuid::new_v4();
455        let tgt = Uuid::new_v4();
456        let assoc = Association::new(src, tgt, LinkType::Related, 0.8);
457        store.put(&env, &assoc).unwrap();
458
459        let got = store.get(&env, src, tgt).unwrap();
460        assert!(got.is_some());
461        let got = got.unwrap();
462        assert_eq!(got.association_type, "related");
463        assert_eq!(got.link_type, LinkType::Related);
464    }
465
466    #[test]
467    fn find_from_and_to() {
468        let (_tmp, env, store) = open_store();
469        let a = Uuid::new_v4();
470        let b = Uuid::new_v4();
471        let c = Uuid::new_v4();
472
473        store
474            .put(&env, &Association::new(a, b, LinkType::Related, 0.5))
475            .unwrap();
476        store
477            .put(&env, &Association::new(a, c, LinkType::Causal, 0.7))
478            .unwrap();
479        store
480            .put(&env, &Association::new(b, c, LinkType::Related, 0.3))
481            .unwrap();
482
483        let from_a = store.find_from(&env, a).unwrap();
484        assert_eq!(from_a.len(), 2);
485
486        let to_c = store.find_to(&env, c).unwrap();
487        assert_eq!(to_c.len(), 2);
488    }
489
490    #[test]
491    fn delete_association() {
492        let (_tmp, env, store) = open_store();
493        let src = Uuid::new_v4();
494        let tgt = Uuid::new_v4();
495        store
496            .put(&env, &Association::new(src, tgt, LinkType::Related, 0.5))
497            .unwrap();
498        assert!(store.delete(&env, src, tgt).unwrap());
499        assert!(store.get(&env, src, tgt).unwrap().is_none());
500    }
501
502    #[test]
503    fn count_associations() {
504        let (_tmp, env, store) = open_store();
505        assert_eq!(store.count(&env).unwrap(), 0);
506        store
507            .put(
508                &env,
509                &Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Related, 0.5),
510            )
511            .unwrap();
512        store
513            .put(
514                &env,
515                &Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Related, 0.5),
516            )
517            .unwrap();
518        assert_eq!(store.count(&env).unwrap(), 2);
519    }
520
521    // ── Phase 6.2: LinkType + Hebbian tests ────────────────────────────
522
523    #[test]
524    fn link_type_default_is_related() {
525        assert_eq!(LinkType::default(), LinkType::Related);
526    }
527
528    #[test]
529    fn link_type_all_has_7_variants() {
530        assert_eq!(LinkType::all().len(), 7);
531    }
532
533    #[test]
534    fn link_type_as_str_matches_expected() {
535        assert_eq!(LinkType::Related.as_str(), "related");
536        assert_eq!(LinkType::Extends.as_str(), "extends");
537        assert_eq!(LinkType::Contradicts.as_str(), "contradicts");
538        assert_eq!(LinkType::Supersedes.as_str(), "supersedes");
539        assert_eq!(LinkType::Temporal.as_str(), "temporal");
540        assert_eq!(LinkType::Causal.as_str(), "causal");
541        assert_eq!(LinkType::Cascade.as_str(), "cascade");
542    }
543
544    #[test]
545    fn link_type_from_str_lossy_roundtrip() {
546        for &lt in LinkType::all() {
547            assert_eq!(LinkType::from_str_lossy(lt.as_str()), lt);
548        }
549        assert_eq!(LinkType::from_str_lossy("unknown"), LinkType::Related);
550    }
551
552    #[test]
553    fn link_type_serde_roundtrip() {
554        for &lt in LinkType::all() {
555            let json = serde_json::to_string(&lt).unwrap();
556            let back: LinkType = serde_json::from_str(&json).unwrap();
557            assert_eq!(lt, back);
558        }
559    }
560
561    #[test]
562    fn new_association_populates_association_type_from_link_type() {
563        let assoc = Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Causal, 0.5);
564        assert_eq!(assoc.association_type, "causal");
565        assert_eq!(assoc.link_type, LinkType::Causal);
566    }
567
568    #[test]
569    fn new_association_has_hebbian_defaults() {
570        let assoc = Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Related, 0.5);
571        assert_eq!(assoc.co_activation_count, 0);
572        assert!((assoc.decay_half_life_days - 90.0).abs() < f32::EPSILON);
573        // last_activated_at should be ~now
574        let now = Utc::now();
575        let diff = (now - assoc.last_activated_at).num_seconds().abs();
576        assert!(diff < 5);
577    }
578
579    #[test]
580    fn activate_boosts_weight() {
581        let mut assoc = Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Related, 0.3);
582        let initial = assoc.weight;
583        assoc.activate();
584        assert!(assoc.weight > initial);
585        assert_eq!(assoc.co_activation_count, 1);
586    }
587
588    #[test]
589    fn activate_has_diminishing_returns() {
590        let mut assoc_high =
591            Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Related, 0.9);
592        assoc_high.activate();
593        let boost_high = 0.1 * (1.0 - 0.9);
594        assert!((assoc_high.weight - (0.9 + boost_high)).abs() < 1e-5);
595
596        let mut assoc_low =
597            Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Related, 0.1);
598        assoc_low.activate();
599        let boost_low = 0.1 * (1.0 - 0.1);
600        assert!((assoc_low.weight - (0.1 + boost_low)).abs() < 1e-5);
601    }
602
603    #[test]
604    fn activate_weight_caps_at_1() {
605        let mut assoc = Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Related, 0.99);
606        for _ in 0..100 {
607            assoc.activate();
608        }
609        assert!(assoc.weight > 0.999);
610        assert!(assoc.weight <= 1.0);
611    }
612
613    #[test]
614    fn decay_reduces_weight_over_time() {
615        let mut assoc = Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Related, 1.0);
616        assoc.decay_half_life_days = 30.0;
617        assoc.last_activated_at = Utc::now() - chrono::Duration::days(30);
618
619        assoc.decay(Utc::now());
620        // After one half-life, weight should be ~0.5
621        assert!((assoc.weight - 0.5).abs() < 0.01);
622    }
623
624    #[test]
625    fn decay_zero_time_is_noop() {
626        let mut assoc = Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Related, 0.7);
627        let w = assoc.weight;
628        assoc.decay(Utc::now());
629        assert!((assoc.weight - w).abs() < f32::EPSILON);
630    }
631
632    #[test]
633    fn decay_uses_per_link_half_life() {
634        let mut short = Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Related, 1.0);
635        short.decay_half_life_days = 7.0;
636        short.last_activated_at = Utc::now() - chrono::Duration::days(7);
637
638        let mut long = Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Related, 1.0);
639        long.decay_half_life_days = 365.0;
640        long.last_activated_at = Utc::now() - chrono::Duration::days(7);
641
642        short.decay(Utc::now());
643        long.decay(Utc::now());
644        assert!(short.weight < long.weight);
645    }
646
647    #[test]
648    fn should_prune_detects_low_weight() {
649        let assoc = Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Related, 0.05);
650        assert!(assoc.should_prune(0.1));
651        assert!(!assoc.should_prune(0.01));
652    }
653
654    #[test]
655    fn with_half_life_days_clamps_to_min_1() {
656        let assoc = Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Related, 0.5)
657            .with_half_life_days(0.1);
658        assert!((assoc.decay_half_life_days - 1.0).abs() < f32::EPSILON);
659    }
660
661    #[test]
662    fn serde_backward_compat_missing_hebbian_fields() {
663        // Old-format association without Phase 6.2 fields
664        let old_json = serde_json::json!({
665            "source": Uuid::new_v4().to_string(),
666            "target": Uuid::new_v4().to_string(),
667            "association_type": "related",
668            "weight": 0.5,
669            "created_at": "2025-01-01T00:00:00Z",
670        });
671
672        let assoc: Association = serde_json::from_value(old_json).unwrap();
673        assert_eq!(assoc.link_type, LinkType::Related);
674        assert_eq!(assoc.co_activation_count, 0);
675        assert!((assoc.decay_half_life_days - 90.0).abs() < f32::EPSILON);
676    }
677
678    #[test]
679    fn msgpack_roundtrip_preserves_hebbian_fields() {
680        let mut assoc = Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Causal, 0.6)
681            .with_half_life_days(14.0);
682        assoc.activate();
683        assoc.activate();
684
685        let bytes = rmp_serde::to_vec(&assoc).unwrap();
686        let back: Association = rmp_serde::from_slice(&bytes).unwrap();
687
688        assert_eq!(back.link_type, LinkType::Causal);
689        assert_eq!(back.association_type, "causal");
690        assert!((back.weight - assoc.weight).abs() < 1e-5);
691        assert_eq!(back.co_activation_count, 2);
692        assert!((back.decay_half_life_days - 14.0).abs() < f32::EPSILON);
693    }
694
695    #[test]
696    fn lmdb_roundtrip_preserves_hebbian_fields() {
697        let (_tmp, env, store) = open_store();
698        let src = Uuid::new_v4();
699        let tgt = Uuid::new_v4();
700
701        let mut assoc =
702            Association::new(src, tgt, LinkType::Contradicts, 0.4).with_half_life_days(7.0);
703        assoc.activate();
704
705        store.put(&env, &assoc).unwrap();
706        let back = store.get(&env, src, tgt).unwrap().unwrap();
707
708        assert_eq!(back.link_type, LinkType::Contradicts);
709        assert_eq!(back.association_type, "contradicts");
710        assert_eq!(back.co_activation_count, 1);
711        assert!((back.decay_half_life_days - 7.0).abs() < f32::EPSILON);
712    }
713
714    #[test]
715    fn find_cycles_detects_circular_links() {
716        let (_tmp, env, store) = open_store();
717        let a = Uuid::new_v4();
718        let b = Uuid::new_v4();
719
720        // Create circular: A→B and B→A
721        store
722            .put(&env, &Association::new(a, b, LinkType::Related, 1.0))
723            .unwrap();
724        store
725            .put(&env, &Association::new(b, a, LinkType::Related, 1.0))
726            .unwrap();
727
728        let cycles = store.find_cycles(&env).unwrap();
729        assert_eq!(cycles.len(), 1, "Should detect one cycle pair");
730        let (s, t) = cycles[0];
731        // The pair should be (min(a,b), max(a,b)) due to s < t filter
732        assert!(s < t);
733        assert!((s == a && t == b) || (s == b && t == a));
734    }
735
736    #[test]
737    fn find_cycles_no_cycles_when_unidirectional() {
738        let (_tmp, env, store) = open_store();
739        let a = Uuid::new_v4();
740        let b = Uuid::new_v4();
741        let c = Uuid::new_v4();
742
743        // Only unidirectional links — no cycles
744        store
745            .put(&env, &Association::new(a, b, LinkType::Related, 0.5))
746            .unwrap();
747        store
748            .put(&env, &Association::new(b, c, LinkType::Causal, 0.7))
749            .unwrap();
750        store
751            .put(&env, &Association::new(a, c, LinkType::Extends, 0.3))
752            .unwrap();
753
754        let cycles = store.find_cycles(&env).unwrap();
755        assert!(
756            cycles.is_empty(),
757            "Unidirectional links should not be cycles"
758        );
759    }
760
761    #[test]
762    fn find_cycles_detects_multiple_cycles() {
763        let (_tmp, env, store) = open_store();
764        let a = Uuid::new_v4();
765        let b = Uuid::new_v4();
766        let c = Uuid::new_v4();
767        let d = Uuid::new_v4();
768
769        // Two separate cycles: A↔B and C↔D
770        store
771            .put(&env, &Association::new(a, b, LinkType::Related, 0.5))
772            .unwrap();
773        store
774            .put(&env, &Association::new(b, a, LinkType::Related, 0.5))
775            .unwrap();
776        store
777            .put(&env, &Association::new(c, d, LinkType::Related, 0.5))
778            .unwrap();
779        store
780            .put(&env, &Association::new(d, c, LinkType::Related, 0.5))
781            .unwrap();
782
783        let cycles = store.find_cycles(&env).unwrap();
784        assert_eq!(cycles.len(), 2, "Should detect two cycle pairs");
785    }
786
787    #[test]
788    fn find_cycles_empty_store() {
789        let (_tmp, env, store) = open_store();
790        let cycles = store.find_cycles(&env).unwrap();
791        assert!(cycles.is_empty());
792    }
793}