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 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 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 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 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 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 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 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 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 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 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 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 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 })
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 #[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 < 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 < in LinkType::all() {
567 let json = serde_json::to_string(<).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 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 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 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 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 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 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 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}