1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum LinkType {
22 #[default]
24 Related,
25 Extends,
27 Contradicts,
29 Supersedes,
31 Temporal,
33 Causal,
35 Cascade,
37}
38
39impl LinkType {
40 #[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 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct Association {
87 pub source: Uuid,
89 pub target: Uuid,
91 #[serde(default = "default_association_type")]
93 pub association_type: String,
94 pub weight: f32,
96 pub created_at: DateTime<Utc>,
98 #[serde(default)]
101 pub link_type: LinkType,
102 #[serde(default)]
104 pub co_activation_count: u32,
105 #[serde(default = "default_last_activated")]
107 pub last_activated_at: DateTime<Utc>,
108 #[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 #[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 #[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 pub fn activate(&mut self) {
155 let now = Utc::now();
156 self.co_activation_count += 1;
157 self.last_activated_at = now;
158
159 let boost = 0.1 * (1.0 - self.weight);
161 self.weight = (self.weight + boost).clamp(0.0, 1.0);
162 }
163
164 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 #[must_use]
179 pub fn should_prune(&self, threshold: f32) -> bool {
180 self.weight < threshold
181 }
182
183 #[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 #[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
202pub struct AssociationStore {
204 db: Database,
205}
206
207impl AssociationStore {
208 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 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 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 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 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 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 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 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 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 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 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 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 })
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 #[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 < 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 < in LinkType::all() {
555 let json = serde_json::to_string(<).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 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 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 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 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 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 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 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}