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