Skip to main content

smos_domain/entities/
fact.rs

1//! `Fact` aggregate root — canonical stored memory.
2//!
3//! The fact is the central aggregate of SMOS. All mutations go through methods
4//! that enforce the aggregate's five invariants; there are no public setters
5//! for individual fields. Status transitions are gated so a finalised fact
6//! cannot be silently resurrected.
7
8use 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/// Canonical English statement about the world, sourced from one or more
18/// sessions, classified by NLI, and retrievable by similarity.
19#[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/// One pool member that survived the cosine merge threshold.
38///
39/// Deliberately not `PartialEq`: comparing full `Fact` clones is brittle (it
40/// drags in `f32` via `Confidence`/`Heat`). Tests compare fields directly.
41#[derive(Debug, Clone)]
42pub struct MergeCandidate {
43    /// Cloned existing fact (a pool member).
44    pub fact: Fact,
45    pub cosine_similarity: Cosine,
46}
47
48impl Fact {
49    /// Construct a fresh pending fact right after extraction.
50    ///
51    /// Defaults match the POC: status `Pending`, confidence `base_confidence`,
52    /// heat `1.0`, fact_type `Entity`, no conflicts, no `valid_until`.
53    /// Confidence and status are recomputed by [`Fact::reclassify`] once NLI
54    /// is available. The caller passes the configured
55    /// [`ConfidenceConfig::base`] so the domain stays free of the
56    /// "default 0.5" hard-coding that bit the POC (a config tweak to
57    /// `confidence.base` was silently ignored at extraction time).
58    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    /// Rehydrate a `Fact` from a persisted representation (storage → domain).
85    ///
86    /// This constructor is the **only** way to rebuild a fact with arbitrary
87    /// field values; every other constructor derives some fields from inputs
88    /// (e.g. `new_pending` derives `id` from content). Persistence adapters
89    /// call this on read so the round-trip `save(f); get(id) == f` holds
90    /// exactly, without recomputation that would silently mutate stored
91    /// confidence/status/heat.
92    ///
93    /// All invariants are still enforced: confidence and heat must be in
94    /// `[0.0, 1.0]`, `valid_until` (when `Some`) must be strictly after
95    /// `valid_from`, and the id must equal `FactId::from_content(content)`.
96    /// The constructor returns the matching `DomainError` if any invariant
97    /// fails so the caller can surface data corruption rather than silently
98    /// re-stamp it.
99    #[allow(clippy::too_many_arguments)] // full-state rehydrate; arg count is inherent
100    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        // Sanity check: the caller-supplied id must match the canonical
117        // content-derived id. If it doesn't, the persisted row is corrupt
118        // (someone wrote a row whose record id disagrees with its content).
119        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        // `valid_until` invariant (mirrors `set_valid_until`).
127        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    /// Recompute confidence + status from NLI context and the active config.
154    ///
155    /// Atomic: either both fields update or neither does. Safe to call with
156    /// `nli = None` to refresh the gate after a provenance change.
157    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    /// Compute the confidence for this fact given optional NLI context.
168    ///
169    /// Formula (§5.4, §9):
170    /// ```text
171    /// score = base
172    ///       + multi_source_bonus     if 2+ distinct sessions observed the fact
173    ///       + no_contradiction_bonus if NLI ran and did not flag a contradiction
174    /// ```
175    ///
176    /// The result is clamped to `[0, 1]` by `Confidence::new_unchecked`.
177    ///
178    /// NOTE: the contradiction check here is intentionally label-only (not the
179    /// threshold-aware `NliResult::is_contradiction`). This faithfully mirrors
180    /// the POC: the bonus rewards "any non-contradiction verdict observed",
181    /// whereas the threshold-aware predicate drives the merge/drift decision.
182    /// Mixing the two would couple two independently-tunable policies.
183    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    /// Stateless heat decay (§7): `heat_base * exp(-decay_rate * hours)`.
205    ///
206    /// Delegates to the canonical [`Heat::decay`] formula. Past access is the
207    /// normal case (positive hours); future timestamps (clock skew) clamp to
208    /// zero so we never amplify heat above `heat_base`.
209    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    /// Scan `pool` for merge candidates against this fact (§5.3 candidate
214    /// selection).
215    ///
216    /// Filters:
217    /// - Same `memory_key` as `self` (cross-namespace matches never merge).
218    /// - Skip the pool member whose id matches `self.id` (would be a self-match).
219    /// - Cosine similarity at/above `cfg.cosine_threshold`.
220    ///
221    /// Results are sorted by cosine similarity descending so the closest
222    /// candidate is processed first.
223    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    /// Mark this fact and `other` as conflicting, on both sides, idempotently.
258    ///
259    /// Convenience wrapper around [`Fact::flag_conflict`] so session-end code
260    /// cannot accidentally flag only one direction (§5.2: both facts must carry
261    /// the conflict link).
262    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    /// Cross-session confirmation (parity with POC `_confirm_existing_fact`).
271    ///
272    /// Adds the session to provenance if it is new, then recomputes confidence
273    /// and re-applies the validation gate — the multi-source bonus (≥2 sessions)
274    /// can lift a single-session `Pending` fact above the accept threshold, at
275    /// which point the status is promoted to `Accepted`. Returns `true` iff the
276    /// provenance grew.
277    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    /// Union provenance and conflict flags from `other`.
290    ///
291    /// Used by the merge path; the caller is responsible for reclassifying
292    /// afterwards. Self-references and duplicates are skipped.
293    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    /// Mark this fact as conflicting with `other_id` (one side of a bidirectional
304    /// flag; caller flags the other side). Rejects self-conflicts.
305    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    /// Rewarm the fact after a retrieval hit (§7 boost).
316    pub fn boost_heat(&mut self, now: Timestamp) {
317        self.heat_base = Heat::MAX;
318        self.last_access_at = now;
319    }
320
321    /// Set the validity tombstone (`valid_until`).
322    ///
323    /// Returns [`DomainError::ValidUntilBeforeValidFrom`] if `until` is at or
324    /// before `valid_from`. `None` clears a previously-set tombstone (fact
325    /// becomes current again).
326    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    /// Set status and confidence together, enforcing all transition invariants.
340    ///
341    /// Rules:
342    /// 1. Outgoing transitions from a terminal state (`Accepted`/`Rejected`)
343    ///    are illegal — only self-refresh is allowed.
344    /// 2. Accepted implies `confidence >= cfg.accept_threshold`.
345    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    // Read-only accessors. No public setters: every mutation goes through a
369    // method that enforces an invariant.
370
371    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    /// Builder-style constructor combinator: replace the embedding.
428    ///
429    /// Exposed because the persistence adapter rebuilds a `Fact` from markdown
430    /// frontmatter and may need to attach (or detach) the in-memory embedding
431    /// after the fact. This is the *only* way to mutate the embedding after
432    /// construction — all other fields stay invariant.
433    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        // No NLI, single source: base 0.5 → Pending.
631        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        // 0.5 base + 0.2 multi_source = 0.7 → Accepted.
657        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        // Persistence adapters call `rehydrate` on read; this test pins the
778        // round-trip contract: every persisted field must survive unchanged.
779        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    // -----------------------------------------------------------------
876    // compute_confidence — multi-source + NLI bonus formula
877    // -----------------------------------------------------------------
878
879    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    // -----------------------------------------------------------------
958    // heat_live — exponential decay
959    // -----------------------------------------------------------------
960
961    #[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        // exp(-0.03 * 24) ≈ 0.4868
971        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    // -----------------------------------------------------------------
987    // find_merge_candidates — cosine similarity scan
988    // -----------------------------------------------------------------
989
990    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        // Orthogonal → cosine 0.0 < 0.85.
1016        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        // Only "perfect" passes the default 0.85 threshold.
1034        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        // Same content → same id → excluded as a self-match.
1043        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    // -----------------------------------------------------------------
1076    // flag_conflict_bidirectional — symmetric conflict flag
1077    // -----------------------------------------------------------------
1078
1079    #[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}