1use crate::config::{ConfidenceConfig, MergeConfig};
9use crate::enums::{FactStatus, FactType, NliLabel};
10use crate::error::DomainError;
11use crate::value_objects::{
12 Confidence, Cosine, Embedding, FactContent, FactId, Heat, MemoryKey, NliResult, SessionId,
13 SourceSessions, Timestamp,
14};
15use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Fact {
21 id: FactId,
22 memory_key: MemoryKey,
23 content: FactContent,
24 fact_type: FactType,
25 confidence: Confidence,
26 status: FactStatus,
27 valid_from: Timestamp,
28 valid_until: Option<Timestamp>,
29 extracted_at: Timestamp,
30 source_sessions: SourceSessions,
31 conflicts_with: Vec<FactId>,
32 heat_base: Heat,
33 last_access_at: Timestamp,
34 embedding: Option<Embedding>,
35}
36
37#[derive(Debug, Clone)]
42pub struct MergeCandidate {
43 pub fact: Fact,
45 pub cosine_similarity: Cosine,
46}
47
48impl Fact {
49 pub fn new_pending(
59 content: &str,
60 memory_key: MemoryKey,
61 session: SessionId,
62 embedding: Embedding,
63 now: Timestamp,
64 base_confidence: f32,
65 ) -> Result<Self, DomainError> {
66 Ok(Self {
67 id: FactId::from_content(content),
68 memory_key,
69 content: FactContent::new(content.to_string())?,
70 fact_type: FactType::Entity,
71 confidence: Confidence::new(base_confidence)?,
72 status: FactStatus::Pending,
73 valid_from: now,
74 valid_until: None,
75 extracted_at: now,
76 source_sessions: SourceSessions::from_one(session),
77 conflicts_with: Vec::new(),
78 heat_base: Heat::new(1.0)?,
79 last_access_at: now,
80 embedding: Some(embedding),
81 })
82 }
83
84 #[allow(clippy::too_many_arguments)] pub fn rehydrate(
101 id: FactId,
102 memory_key: MemoryKey,
103 content: FactContent,
104 fact_type: FactType,
105 confidence: Confidence,
106 status: FactStatus,
107 valid_from: Timestamp,
108 valid_until: Option<Timestamp>,
109 extracted_at: Timestamp,
110 source_sessions: SourceSessions,
111 conflicts_with: Vec<FactId>,
112 heat_base: Heat,
113 last_access_at: Timestamp,
114 embedding: Option<Embedding>,
115 ) -> Result<Self, DomainError> {
116 if id != FactId::from_content(content.as_str()) {
120 return Err(DomainError::InvalidFactId(format!(
121 "rehydrate id mismatch: record={} expected={}",
122 id,
123 FactId::from_content(content.as_str())
124 )));
125 }
126 if let Some(until) = valid_until
128 && until <= valid_from
129 {
130 return Err(DomainError::ValidUntilBeforeValidFrom {
131 from: valid_from,
132 until,
133 });
134 }
135 Ok(Self {
136 id,
137 memory_key,
138 content,
139 fact_type,
140 confidence,
141 status,
142 valid_from,
143 valid_until,
144 extracted_at,
145 source_sessions,
146 conflicts_with,
147 heat_base,
148 last_access_at,
149 embedding,
150 })
151 }
152
153 pub fn reclassify(
158 &mut self,
159 nli: Option<&NliResult>,
160 cfg: &ConfidenceConfig,
161 ) -> Result<(), DomainError> {
162 let new_conf = self.compute_confidence(nli, cfg);
163 let new_status = new_conf.classify(cfg);
164 self.set_status_and_confidence(new_status, new_conf, cfg)
165 }
166
167 pub fn compute_confidence(
184 &self,
185 nli: Option<&NliResult>,
186 cfg: &ConfidenceConfig,
187 ) -> Confidence {
188 let mut score = cfg.base;
189
190 if self.source_sessions.distinct_count() >= 2 {
191 score += cfg.multi_source_bonus;
192 }
193
194 if let Some(nli) = nli
195 && nli.available
196 && nli.label != NliLabel::Contradiction
197 {
198 score += cfg.no_contradiction_bonus;
199 }
200
201 Confidence::new_unchecked(score)
202 }
203
204 pub fn heat_live(&self, now: Timestamp, decay_rate: f32) -> f32 {
210 Heat::decay(self.heat_base, self.last_access_at, now, decay_rate)
211 }
212
213 pub fn find_merge_candidates(&self, pool: &[Fact], cfg: &MergeConfig) -> Vec<MergeCandidate> {
224 let Some(self_emb) = self.embedding.as_ref() else {
225 return Vec::new();
226 };
227 let self_key = &self.memory_key;
228 let self_id = &self.id;
229
230 let mut candidates: Vec<MergeCandidate> = pool
231 .iter()
232 .filter(|f| &f.memory_key == self_key)
233 .filter(|f| &f.id != self_id)
234 .filter_map(|f| {
235 let emb = f.embedding()?;
236 let sim = self_emb.cosine(emb);
237 if sim.value() >= cfg.cosine_threshold {
238 Some(MergeCandidate {
239 fact: f.clone(),
240 cosine_similarity: sim,
241 })
242 } else {
243 None
244 }
245 })
246 .collect();
247
248 candidates.sort_by(|a, b| {
249 b.cosine_similarity
250 .value()
251 .partial_cmp(&a.cosine_similarity.value())
252 .unwrap_or(std::cmp::Ordering::Equal)
253 });
254 candidates
255 }
256
257 pub fn flag_conflict_bidirectional(&mut self, other: &mut Fact) -> Result<(), DomainError> {
263 let self_id = self.id.clone();
264 let other_id = other.id.clone();
265 self.flag_conflict(other_id)?;
266 other.flag_conflict(self_id)?;
267 Ok(())
268 }
269
270 pub fn confirm_cross_session(
278 &mut self,
279 session: &SessionId,
280 cfg: &ConfidenceConfig,
281 ) -> Result<bool, DomainError> {
282 let grew = self.source_sessions.add_unique(session.clone());
283 if grew {
284 self.reclassify(None, cfg)?;
285 }
286 Ok(grew)
287 }
288
289 pub fn merge_into(&mut self, other: &Fact) -> Result<(), DomainError> {
294 self.source_sessions.union(&other.source_sessions);
295 for cid in &other.conflicts_with {
296 if *cid != self.id && !self.conflicts_with.contains(cid) {
297 self.conflicts_with.push(cid.clone());
298 }
299 }
300 Ok(())
301 }
302
303 pub fn flag_conflict(&mut self, other_id: FactId) -> Result<(), DomainError> {
306 if other_id == self.id {
307 return Err(DomainError::SelfConflict(self.id.clone()));
308 }
309 if !self.conflicts_with.contains(&other_id) {
310 self.conflicts_with.push(other_id);
311 }
312 Ok(())
313 }
314
315 pub fn boost_heat(&mut self, now: Timestamp) {
317 self.heat_base = Heat::MAX;
318 self.last_access_at = now;
319 }
320
321 pub fn set_valid_until(&mut self, until: Option<Timestamp>) -> Result<(), DomainError> {
327 if let Some(ts) = until
328 && ts <= self.valid_from
329 {
330 return Err(DomainError::ValidUntilBeforeValidFrom {
331 from: self.valid_from,
332 until: ts,
333 });
334 }
335 self.valid_until = until;
336 Ok(())
337 }
338
339 pub fn set_status_and_confidence(
346 &mut self,
347 status: FactStatus,
348 conf: Confidence,
349 cfg: &ConfidenceConfig,
350 ) -> Result<(), DomainError> {
351 if self.status.is_terminal() && status != self.status {
352 return Err(DomainError::IllegalStatusTransition {
353 from: self.status,
354 to: status,
355 });
356 }
357 if status == FactStatus::Accepted && conf.value() < cfg.accept_threshold {
358 return Err(DomainError::ConfidenceBelowAcceptThreshold {
359 threshold: cfg.accept_threshold,
360 actual: conf.value(),
361 });
362 }
363 self.status = status;
364 self.confidence = conf;
365 Ok(())
366 }
367
368 pub fn id(&self) -> &FactId {
372 &self.id
373 }
374
375 pub fn memory_key(&self) -> &MemoryKey {
376 &self.memory_key
377 }
378
379 pub fn content(&self) -> &str {
380 self.content.as_str()
381 }
382
383 pub fn fact_type(&self) -> FactType {
384 self.fact_type
385 }
386
387 pub fn confidence(&self) -> Confidence {
388 self.confidence
389 }
390
391 pub fn status(&self) -> FactStatus {
392 self.status
393 }
394
395 pub fn valid_from(&self) -> Timestamp {
396 self.valid_from
397 }
398
399 pub fn valid_until(&self) -> Option<Timestamp> {
400 self.valid_until
401 }
402
403 pub fn extracted_at(&self) -> Timestamp {
404 self.extracted_at
405 }
406
407 pub fn source_sessions(&self) -> &SourceSessions {
408 &self.source_sessions
409 }
410
411 pub fn conflicts_with(&self) -> &[FactId] {
412 &self.conflicts_with
413 }
414
415 pub fn heat_base(&self) -> Heat {
416 self.heat_base
417 }
418
419 pub fn last_access_at(&self) -> Timestamp {
420 self.last_access_at
421 }
422
423 pub fn embedding(&self) -> Option<&Embedding> {
424 self.embedding.as_ref()
425 }
426
427 pub fn with_embedding(mut self, embedding: Option<Embedding>) -> Self {
434 self.embedding = embedding;
435 self
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442 use crate::value_objects::SessionId;
443
444 fn sid(suffix: u8) -> SessionId {
445 let hex = format!("sess_{:012x}", suffix as u64);
446 SessionId::from_raw(&hex).unwrap()
447 }
448
449 fn emb(dim: usize) -> Embedding {
450 Embedding::new((0..dim).map(|i| i as f32 + 1.0).collect()).unwrap()
451 }
452
453 fn pending_fact(content: &str, session: SessionId) -> Fact {
454 Fact::new_pending(
455 content,
456 MemoryKey::from_raw("origa").unwrap(),
457 session,
458 emb(8),
459 Timestamp::from_unix_secs(1_700_000_000).unwrap(),
460 ConfidenceConfig::default().base,
461 )
462 .unwrap()
463 }
464
465 fn default_cfg() -> ConfidenceConfig {
466 ConfidenceConfig::default()
467 }
468
469 #[test]
470 fn new_pending_initialises_all_fields() {
471 let session = sid(1);
472 let fact = pending_fact("Rust is fast", session.clone());
473
474 assert_eq!(fact.content(), "Rust is fast");
475 assert_eq!(fact.memory_key().as_str(), "origa");
476 assert_eq!(fact.fact_type(), FactType::Entity);
477 assert_eq!(fact.confidence().value(), 0.5);
478 assert_eq!(fact.status(), FactStatus::Pending);
479 assert!(fact.valid_until().is_none());
480 assert_eq!(fact.source_sessions().distinct_count(), 1);
481 assert!(fact.conflicts_with().is_empty());
482 assert_eq!(fact.heat_base().value(), 1.0);
483 assert!(fact.embedding().is_some());
484 assert_eq!(fact.id(), &FactId::from_content("Rust is fast"));
485 }
486
487 #[test]
488 fn new_pending_rejects_empty_content() {
489 let err = Fact::new_pending(
490 " ",
491 MemoryKey::shared(),
492 sid(1),
493 emb(4),
494 Timestamp::from_unix_secs(0).unwrap(),
495 ConfidenceConfig::default().base,
496 )
497 .unwrap_err();
498 assert!(matches!(err, DomainError::EmptyFactContent));
499 }
500
501 #[test]
502 fn set_status_pending_to_accepted_when_confidence_is_high_enough() {
503 let mut fact = pending_fact("a", sid(1));
504 fact.set_status_and_confidence(
505 FactStatus::Accepted,
506 Confidence::new(0.7).unwrap(),
507 &default_cfg(),
508 )
509 .unwrap();
510 assert_eq!(fact.status(), FactStatus::Accepted);
511 }
512
513 #[test]
514 fn set_status_pending_to_accepted_rejects_low_confidence() {
515 let mut fact = pending_fact("a", sid(1));
516 let err = fact
517 .set_status_and_confidence(
518 FactStatus::Accepted,
519 Confidence::new(0.5).unwrap(),
520 &default_cfg(),
521 )
522 .unwrap_err();
523 assert!(matches!(
524 err,
525 DomainError::ConfidenceBelowAcceptThreshold {
526 threshold: 0.7,
527 actual: 0.5
528 }
529 ));
530 }
531
532 #[test]
533 fn set_status_pending_to_rejected_is_allowed() {
534 let mut fact = pending_fact("a", sid(1));
535 fact.set_status_and_confidence(
536 FactStatus::Rejected,
537 Confidence::new(0.0).unwrap(),
538 &default_cfg(),
539 )
540 .unwrap();
541 assert_eq!(fact.status(), FactStatus::Rejected);
542 }
543
544 #[test]
545 fn set_status_accepted_to_accepted_is_allowed_for_refresh() {
546 let mut fact = pending_fact("a", sid(1));
547 fact.set_status_and_confidence(
548 FactStatus::Accepted,
549 Confidence::new(0.9).unwrap(),
550 &default_cfg(),
551 )
552 .unwrap();
553 fact.set_status_and_confidence(
554 FactStatus::Accepted,
555 Confidence::new(0.95).unwrap(),
556 &default_cfg(),
557 )
558 .unwrap();
559 assert_eq!(fact.confidence().value(), 0.95);
560 }
561
562 #[test]
563 fn set_status_accepted_to_pending_is_illegal() {
564 let mut fact = pending_fact("a", sid(1));
565 fact.set_status_and_confidence(
566 FactStatus::Accepted,
567 Confidence::new(0.9).unwrap(),
568 &default_cfg(),
569 )
570 .unwrap();
571 let err = fact
572 .set_status_and_confidence(
573 FactStatus::Pending,
574 Confidence::new(0.5).unwrap(),
575 &default_cfg(),
576 )
577 .unwrap_err();
578 assert!(matches!(
579 err,
580 DomainError::IllegalStatusTransition {
581 from: FactStatus::Accepted,
582 to: FactStatus::Pending
583 }
584 ));
585 }
586
587 #[test]
588 fn set_status_accepted_to_rejected_is_illegal() {
589 let mut fact = pending_fact("a", sid(1));
590 fact.set_status_and_confidence(
591 FactStatus::Accepted,
592 Confidence::new(0.9).unwrap(),
593 &default_cfg(),
594 )
595 .unwrap();
596 assert!(
597 fact.set_status_and_confidence(
598 FactStatus::Rejected,
599 Confidence::new(0.0).unwrap(),
600 &default_cfg(),
601 )
602 .is_err()
603 );
604 }
605
606 #[test]
607 fn set_status_rejected_to_anything_is_illegal() {
608 let mut fact = pending_fact("a", sid(1));
609 fact.set_status_and_confidence(
610 FactStatus::Rejected,
611 Confidence::new(0.0).unwrap(),
612 &default_cfg(),
613 )
614 .unwrap();
615 for target in [FactStatus::Pending, FactStatus::Accepted] {
616 assert!(
617 fact.set_status_and_confidence(
618 target,
619 Confidence::new(0.5).unwrap(),
620 &default_cfg()
621 )
622 .is_err()
623 );
624 }
625 }
626
627 #[test]
628 fn reclassify_applies_confidence_and_status_atomically() {
629 let mut fact = pending_fact("a", sid(1));
630 fact.reclassify(None, &default_cfg()).unwrap();
632 assert_eq!(fact.confidence().value(), 0.5);
633 assert_eq!(fact.status(), FactStatus::Pending);
634 }
635
636 #[test]
637 fn confirm_cross_session_adds_session_first_time() {
638 let mut fact = pending_fact("a", sid(1));
639 let grew = fact.confirm_cross_session(&sid(2), &default_cfg()).unwrap();
640 assert!(grew);
641 assert_eq!(fact.source_sessions().distinct_count(), 2);
642 }
643
644 #[test]
645 fn confirm_cross_session_returns_false_on_repeat() {
646 let mut fact = pending_fact("a", sid(1));
647 fact.confirm_cross_session(&sid(2), &default_cfg()).unwrap();
648 let grew = fact.confirm_cross_session(&sid(2), &default_cfg()).unwrap();
649 assert!(!grew);
650 }
651
652 #[test]
653 fn confirm_cross_session_lifts_confidence_to_accept_threshold() {
654 let mut fact = pending_fact("a", sid(1));
655 fact.confirm_cross_session(&sid(2), &default_cfg()).unwrap();
656 assert!((fact.confidence().value() - 0.7).abs() < 1e-6);
658 assert_eq!(fact.status(), FactStatus::Accepted);
659 }
660
661 #[test]
662 fn merge_into_unions_source_sessions() {
663 let mut left = pending_fact("a", sid(1));
664 let mut right = pending_fact("a", sid(2));
665 right
666 .confirm_cross_session(&sid(3), &default_cfg())
667 .unwrap();
668 left.merge_into(&right).unwrap();
669 assert_eq!(left.source_sessions().distinct_count(), 3);
670 }
671
672 #[test]
673 fn merge_into_unions_conflicts_without_self_reference() {
674 let mut left = pending_fact("a", sid(1));
675 let other_id = FactId::from_content("other");
676 left.flag_conflict(other_id.clone()).unwrap();
677 let right = pending_fact("a", sid(2));
678 left.merge_into(&right).unwrap();
679 assert!(left.conflicts_with().contains(&other_id));
680 }
681
682 #[test]
683 fn merge_into_dedups_conflict_flags() {
684 let mut left = pending_fact("a", sid(1));
685 let other_id = FactId::from_content("other");
686 left.flag_conflict(other_id.clone()).unwrap();
687 let mut right = pending_fact("a", sid(2));
688 right.flag_conflict(other_id.clone()).unwrap();
689 left.merge_into(&right).unwrap();
690 let count = left
691 .conflicts_with()
692 .iter()
693 .filter(|id| **id == other_id)
694 .count();
695 assert_eq!(count, 1);
696 }
697
698 #[test]
699 fn flag_conflict_rejects_self_conflict() {
700 let mut fact = pending_fact("a", sid(1));
701 let err = fact.flag_conflict(fact.id().clone()).unwrap_err();
702 assert!(matches!(err, DomainError::SelfConflict(_)));
703 }
704
705 #[test]
706 fn flag_conflict_is_idempotent() {
707 let mut fact = pending_fact("a", sid(1));
708 let other = FactId::from_content("other");
709 fact.flag_conflict(other.clone()).unwrap();
710 fact.flag_conflict(other.clone()).unwrap();
711 assert_eq!(
712 fact.conflicts_with()
713 .iter()
714 .filter(|id| **id == other)
715 .count(),
716 1
717 );
718 }
719
720 #[test]
721 fn boost_heat_sets_max_heat_and_refreshes_access_time() {
722 let mut fact = pending_fact("a", sid(1));
723 let now = Timestamp::from_unix_secs(1_800_000_000).unwrap();
724 fact.boost_heat(now);
725 assert_eq!(fact.heat_base().value(), 1.0);
726 assert_eq!(fact.last_access_at().as_unix_secs(), 1_800_000_000);
727 }
728
729 #[test]
730 fn set_valid_until_accepts_timestamp_strictly_after_valid_from() {
731 let mut fact = pending_fact("a", sid(1));
732 let original_valid_from = fact.valid_from();
733 let later = Timestamp::from_unix_secs(original_valid_from.as_unix_secs() + 3600).unwrap();
734 fact.set_valid_until(Some(later)).unwrap();
735 assert_eq!(fact.valid_until(), Some(later));
736 }
737
738 #[test]
739 fn set_valid_until_rejects_timestamp_at_or_before_valid_from() {
740 let mut fact = pending_fact("a", sid(1));
741 let original_valid_from = fact.valid_from();
742 let equal = original_valid_from;
743 let earlier = Timestamp::from_unix_secs(original_valid_from.as_unix_secs() - 10).unwrap();
744 assert!(fact.set_valid_until(Some(equal)).is_err());
745 assert!(fact.set_valid_until(Some(earlier)).is_err());
746 }
747
748 #[test]
749 fn set_valid_until_none_clears_tombstone() {
750 let mut fact = pending_fact("a", sid(1));
751 let original = fact.valid_from();
752 let later = Timestamp::from_unix_secs(original.as_unix_secs() + 3600).unwrap();
753 fact.set_valid_until(Some(later)).unwrap();
754 fact.set_valid_until(None).unwrap();
755 assert!(fact.valid_until().is_none());
756 }
757
758 #[test]
759 fn with_embedding_overrides_embedding() {
760 let fact = pending_fact("a", sid(1));
761 let replaced = fact.with_embedding(None);
762 assert!(replaced.embedding().is_none());
763 }
764
765 #[test]
766 fn serde_roundtrip_preserves_fact_fields() {
767 let fact = pending_fact("Rust fact", sid(1));
768 let json = serde_json::to_string(&fact).unwrap();
769 let back: Fact = serde_json::from_str(&json).unwrap();
770 assert_eq!(back.content(), "Rust fact");
771 assert_eq!(back.status(), FactStatus::Pending);
772 assert_eq!(back.confidence().value(), 0.5);
773 }
774
775 #[test]
776 fn rehydrate_roundtrips_every_field_verbatim() {
777 let mut fact = pending_fact("Rust fact", sid(1));
780 fact.set_status_and_confidence(
781 FactStatus::Accepted,
782 Confidence::new(0.92).unwrap(),
783 &default_cfg(),
784 )
785 .unwrap();
786 fact.flag_conflict(FactId::from_content("other")).unwrap();
787 fact.set_valid_until(Some(
788 Timestamp::from_unix_secs(fact.valid_from().as_unix_secs() + 3600).unwrap(),
789 ))
790 .unwrap();
791
792 let rehydrated = Fact::rehydrate(
793 fact.id().clone(),
794 fact.memory_key().clone(),
795 FactContent::new(fact.content().to_string()).unwrap(),
796 fact.fact_type(),
797 fact.confidence(),
798 fact.status(),
799 fact.valid_from(),
800 fact.valid_until(),
801 fact.extracted_at(),
802 fact.source_sessions().clone(),
803 fact.conflicts_with().to_vec(),
804 fact.heat_base(),
805 fact.last_access_at(),
806 fact.embedding().cloned(),
807 )
808 .unwrap();
809
810 assert_eq!(rehydrated.id(), fact.id());
811 assert_eq!(rehydrated.content(), fact.content());
812 assert_eq!(rehydrated.fact_type(), fact.fact_type());
813 assert_eq!(rehydrated.confidence().value(), fact.confidence().value());
814 assert_eq!(rehydrated.status(), fact.status());
815 assert_eq!(rehydrated.valid_from(), fact.valid_from());
816 assert_eq!(rehydrated.valid_until(), fact.valid_until());
817 assert_eq!(rehydrated.extracted_at(), fact.extracted_at());
818 assert_eq!(
819 rehydrated.source_sessions().distinct_count(),
820 fact.source_sessions().distinct_count()
821 );
822 assert_eq!(rehydrated.conflicts_with(), fact.conflicts_with());
823 assert_eq!(rehydrated.heat_base().value(), fact.heat_base().value());
824 assert_eq!(rehydrated.last_access_at(), fact.last_access_at());
825 }
826
827 #[test]
828 fn rehydrate_rejects_id_that_disagrees_with_content() {
829 let fact = pending_fact("Rust fact", sid(1));
830 let wrong_id = FactId::from_content("different content");
831 let err = Fact::rehydrate(
832 wrong_id.clone(),
833 fact.memory_key().clone(),
834 FactContent::new(fact.content().to_string()).unwrap(),
835 fact.fact_type(),
836 fact.confidence(),
837 fact.status(),
838 fact.valid_from(),
839 None,
840 fact.extracted_at(),
841 fact.source_sessions().clone(),
842 Vec::new(),
843 fact.heat_base(),
844 fact.last_access_at(),
845 None,
846 )
847 .unwrap_err();
848 assert!(matches!(err, DomainError::InvalidFactId(_)));
849 }
850
851 #[test]
852 fn rehydrate_rejects_valid_until_at_or_before_valid_from() {
853 let fact = pending_fact("Rust fact", sid(1));
854 let at_valid_from = fact.valid_from();
855 let err = Fact::rehydrate(
856 fact.id().clone(),
857 fact.memory_key().clone(),
858 FactContent::new(fact.content().to_string()).unwrap(),
859 fact.fact_type(),
860 fact.confidence(),
861 fact.status(),
862 fact.valid_from(),
863 Some(at_valid_from),
864 fact.extracted_at(),
865 fact.source_sessions().clone(),
866 Vec::new(),
867 fact.heat_base(),
868 fact.last_access_at(),
869 None,
870 )
871 .unwrap_err();
872 assert!(matches!(err, DomainError::ValidUntilBeforeValidFrom { .. }));
873 }
874
875 fn nli_result(label: NliLabel, available: bool) -> NliResult {
880 use crate::value_objects::NliScores;
881 NliResult {
882 label,
883 scores: NliScores {
884 entailment: if label == NliLabel::Entailment {
885 1.0
886 } else {
887 0.0
888 },
889 neutral: if label == NliLabel::Neutral { 1.0 } else { 0.0 },
890 contradiction: if label == NliLabel::Contradiction {
891 1.0
892 } else {
893 0.0
894 },
895 },
896 available,
897 }
898 }
899
900 #[test]
901 fn compute_confidence_base_only_for_single_source_no_nli() {
902 let fact = pending_fact("a", sid(1));
903 let c = fact.compute_confidence(None, &default_cfg());
904 assert!((c.value() - 0.5).abs() < 1e-6);
905 }
906
907 #[test]
908 fn compute_confidence_multi_source_bonus_applies_with_two_sessions() {
909 let mut fact = pending_fact("a", sid(1));
910 fact.confirm_cross_session(&sid(2), &default_cfg()).unwrap();
911 let c = fact.compute_confidence(None, &default_cfg());
912 assert!((c.value() - 0.7).abs() < 1e-6);
913 }
914
915 #[test]
916 fn compute_confidence_no_contradiction_bonus_for_entailment() {
917 let fact = pending_fact("a", sid(1));
918 let c = fact.compute_confidence(
919 Some(&nli_result(NliLabel::Entailment, true)),
920 &default_cfg(),
921 );
922 assert!((c.value() - 0.6).abs() < 1e-6);
923 }
924
925 #[test]
926 fn compute_confidence_no_contradiction_bonus_skipped_for_contradiction() {
927 let fact = pending_fact("a", sid(1));
928 let c = fact.compute_confidence(
929 Some(&nli_result(NliLabel::Contradiction, true)),
930 &default_cfg(),
931 );
932 assert!((c.value() - 0.5).abs() < 1e-6);
933 }
934
935 #[test]
936 fn compute_confidence_no_contradiction_bonus_skipped_when_unavailable() {
937 let fact = pending_fact("a", sid(1));
938 let c = fact.compute_confidence(
939 Some(&nli_result(NliLabel::Entailment, false)),
940 &default_cfg(),
941 );
942 assert!((c.value() - 0.5).abs() < 1e-6);
943 }
944
945 #[test]
946 fn compute_confidence_both_bonuses_stack_and_clamp_at_one() {
947 let mut fact = pending_fact("a", sid(1));
948 fact.confirm_cross_session(&sid(2), &default_cfg()).unwrap();
949 fact.confirm_cross_session(&sid(3), &default_cfg()).unwrap();
950 let c = fact.compute_confidence(
951 Some(&nli_result(NliLabel::Entailment, true)),
952 &default_cfg(),
953 );
954 assert!((c.value() - 0.8).abs() < 1e-6);
955 }
956
957 #[test]
962 fn heat_live_fresh_fact_has_full_heat() {
963 let fact = pending_fact("a", sid(1));
964 let now = fact.last_access_at();
965 assert!((fact.heat_live(now, 0.03) - 1.0).abs() < 1e-6);
966 }
967
968 #[test]
969 fn heat_live_decays_after_24_hours_at_known_rate() {
970 let fact = pending_fact("a", sid(1));
972 let base = fact.last_access_at();
973 let one_day_later = Timestamp::from_unix_secs(base.as_unix_secs() + 24 * 3600).unwrap();
974 let h = fact.heat_live(one_day_later, 0.03);
975 assert!((h - 0.4868).abs() < 1e-3, "got {h}");
976 }
977
978 #[test]
979 fn heat_live_future_access_clamps_to_zero_decay() {
980 let fact = pending_fact("a", sid(1));
981 let base = fact.last_access_at();
982 let earlier = Timestamp::from_unix_secs(base.as_unix_secs() - 3600).unwrap();
983 assert!((fact.heat_live(earlier, 0.03) - 1.0).abs() < 1e-6);
984 }
985
986 fn fact_with_key_embedding(content: &str, key: &str, embedding: Vec<f32>) -> Fact {
991 Fact::new_pending(
992 content,
993 MemoryKey::from_raw(key).unwrap(),
994 sid(1),
995 Embedding::new(embedding).unwrap(),
996 Timestamp::from_unix_secs(0).unwrap(),
997 ConfidenceConfig::default().base,
998 )
999 .unwrap()
1000 }
1001
1002 #[test]
1003 fn find_merge_candidates_empty_pool_returns_empty() {
1004 let pending = fact_with_key_embedding("p", "origa", vec![1.0, 0.0]);
1005 assert!(
1006 pending
1007 .find_merge_candidates(&[], &MergeConfig::default())
1008 .is_empty()
1009 );
1010 }
1011
1012 #[test]
1013 fn find_merge_candidates_filters_below_threshold() {
1014 let pending = fact_with_key_embedding("p", "origa", vec![1.0, 0.0]);
1015 let pool = vec![fact_with_key_embedding("x", "origa", vec![0.0, 1.0])];
1017 assert!(
1018 pending
1019 .find_merge_candidates(&pool, &MergeConfig::default())
1020 .is_empty()
1021 );
1022 }
1023
1024 #[test]
1025 fn find_merge_candidates_keeps_above_threshold_sorted_desc() {
1026 let pending = fact_with_key_embedding("p", "origa", vec![1.0, 0.0]);
1027 let pool = vec![
1028 fact_with_key_embedding("ortho", "origa", vec![0.0, 1.0]),
1029 fact_with_key_embedding("mid", "origa", vec![1.0, 1.0]),
1030 fact_with_key_embedding("perfect", "origa", vec![1.0, 0.0]),
1031 ];
1032 let out = pending.find_merge_candidates(&pool, &MergeConfig::default());
1033 assert_eq!(out.len(), 1);
1035 assert_eq!(out[0].fact.content(), "perfect");
1036 assert!((out[0].cosine_similarity.value() - 1.0).abs() < 1e-5);
1037 }
1038
1039 #[test]
1040 fn find_merge_candidates_excludes_self_id() {
1041 let pending = fact_with_key_embedding("same", "origa", vec![1.0, 0.0]);
1042 let pool = vec![fact_with_key_embedding("same", "origa", vec![1.0, 0.0])];
1044 assert!(
1045 pending
1046 .find_merge_candidates(&pool, &MergeConfig::default())
1047 .is_empty()
1048 );
1049 }
1050
1051 #[test]
1052 fn find_merge_candidates_excludes_different_memory_key() {
1053 let pending = fact_with_key_embedding("p", "origa", vec![1.0, 0.0]);
1054 let pool = vec![fact_with_key_embedding("x", "other", vec![1.0, 0.0])];
1055 assert!(
1056 pending
1057 .find_merge_candidates(&pool, &MergeConfig::default())
1058 .is_empty()
1059 );
1060 }
1061
1062 #[test]
1063 fn find_merge_candidates_skips_pool_member_without_embedding() {
1064 let pending = fact_with_key_embedding("p", "origa", vec![1.0, 0.0]);
1065 let pool_member =
1066 fact_with_key_embedding("x", "origa", vec![1.0, 0.0]).with_embedding(None);
1067 let pool = vec![pool_member];
1068 assert!(
1069 pending
1070 .find_merge_candidates(&pool, &MergeConfig::default())
1071 .is_empty()
1072 );
1073 }
1074
1075 #[test]
1080 fn flag_conflict_bidirectional_sets_both_sides() {
1081 let mut a = pending_fact("alpha", sid(1));
1082 let mut b = pending_fact("beta", sid(2));
1083 a.flag_conflict_bidirectional(&mut b).unwrap();
1084 assert!(a.conflicts_with().contains(b.id()));
1085 assert!(b.conflicts_with().contains(a.id()));
1086 }
1087
1088 #[test]
1089 fn flag_conflict_bidirectional_is_idempotent() {
1090 let mut a = pending_fact("alpha", sid(1));
1091 let mut b = pending_fact("beta", sid(2));
1092 a.flag_conflict_bidirectional(&mut b).unwrap();
1093 a.flag_conflict_bidirectional(&mut b).unwrap();
1094 assert_eq!(a.conflicts_with().len(), 1);
1095 assert_eq!(b.conflicts_with().len(), 1);
1096 }
1097}