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
48/// Named-argument bundle for [`Fact::rehydrate`]: the full persisted
49/// state of a `Fact`, one field per positional parameter of [`Fact::rehydrate`].
50///
51/// Persistence adapters assemble this on read and hand it to
52/// [`Fact::rehydrate`]; the record-struct shape makes call sites
53/// order-independent and self-documenting, eliminating the 14-argument
54/// positional data-clump. Field types and order mirror `Fact` itself so the
55/// round-trip `save(f); get(id) == f` holds exactly.
56#[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/// Named-argument bundle for [`Fact::new_pending`]: the six inputs a
75/// freshly-extracted pending fact needs.
76///
77/// `content` borrows (lifetime `'a`) so a caller holding a `&str` from the
78/// extraction pipeline can construct the request without an intermediate
79/// allocation. The borrowing request is consumed synchronously by
80/// [`Fact::new_pending`], which returns an owned [`Fact`].
81#[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    /// Construct a fresh pending fact right after extraction.
93    ///
94    /// Defaults match the POC: status `Pending`, confidence `base_confidence`,
95    /// heat `1.0`, fact_type `Entity`, no conflicts, no `valid_until`.
96    /// Confidence and status are recomputed by [`Fact::reclassify`] once NLI
97    /// is available. The caller passes the configured
98    /// [`ConfidenceConfig::base`] (via [`NewPendingRequest::base_confidence`])
99    /// so the domain stays free of the "default 0.5" hard-coding that bit the
100    /// POC (a config tweak to `confidence.base` was silently ignored at
101    /// extraction time).
102    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    /// Rehydrate a `Fact` from a persisted representation (storage → domain).
122    ///
123    /// This constructor is the **only** way to rebuild a fact with arbitrary
124    /// field values; every other constructor derives some fields from inputs
125    /// (e.g. [`Fact::new_pending`] derives `id` from content). Persistence
126    /// adapters assemble a [`FactRecord`] on read and call this so the
127    /// round-trip `save(f); get(id) == f` holds exactly, without recomputation
128    /// that would silently mutate stored confidence/status/heat.
129    ///
130    /// All invariants are still enforced: confidence and heat must be in
131    /// `[0.0, 1.0]`, `valid_until` (when `Some`) must be strictly after
132    /// `valid_from`, and the id must equal `FactId::from_content(content)`.
133    /// The constructor returns the matching `DomainError` if any invariant
134    /// fails so the caller can surface data corruption rather than silently
135    /// re-stamp it.
136    pub fn rehydrate(rec: FactRecord) -> Result<Self, DomainError> {
137        // Sanity check: the caller-supplied id must match the canonical
138        // content-derived id. If it doesn't, the persisted row is corrupt
139        // (someone wrote a row whose record id disagrees with its content).
140        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        // `valid_until` invariant (mirrors `set_valid_until`).
148        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    /// Recompute confidence + status from NLI context and the active config.
175    ///
176    /// Atomic: either both fields update or neither does. Safe to call with
177    /// `nli = None` to refresh the gate after a provenance change.
178    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    /// Compute the confidence for this fact given optional NLI context.
189    ///
190    /// Formula (§5.4, §9):
191    /// ```text
192    /// score = base
193    ///       + multi_source_bonus     if 2+ distinct sessions observed the fact
194    ///       + no_contradiction_bonus if NLI ran and did not flag a contradiction
195    /// ```
196    ///
197    /// The result is clamped to `[0, 1]` by `Confidence::new_unchecked`.
198    ///
199    /// NOTE: the contradiction check here is intentionally label-only (not the
200    /// threshold-aware `NliResult::is_contradiction`). This faithfully mirrors
201    /// the POC: the bonus rewards "any non-contradiction verdict observed",
202    /// whereas the threshold-aware predicate drives the merge/drift decision.
203    /// Mixing the two would couple two independently-tunable policies.
204    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    /// Stateless heat decay (§7): `heat_base * exp(-decay_rate * hours)`.
226    ///
227    /// Delegates to the canonical [`Heat::decay`] formula. Past access is the
228    /// normal case (positive hours); future timestamps (clock skew) clamp to
229    /// zero so we never amplify heat above `heat_base`.
230    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    /// Scan `pool` for merge candidates against this fact (§5.3 candidate
235    /// selection).
236    ///
237    /// Filters:
238    /// - Same `memory_key` as `self` (cross-namespace matches never merge).
239    /// - Skip the pool member whose id matches `self.id` (would be a self-match).
240    /// - Cosine similarity at/above `cfg.cosine_threshold`.
241    ///
242    /// Results are sorted by cosine similarity descending so the closest
243    /// candidate is processed first.
244    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    /// Mark this fact and `other` as conflicting, on both sides, idempotently.
279    ///
280    /// Convenience wrapper around [`Fact::flag_conflict`] so session-end code
281    /// cannot accidentally flag only one direction (§5.2: both facts must carry
282    /// the conflict link).
283    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    /// Cross-session confirmation (parity with POC `_confirm_existing_fact`).
292    ///
293    /// Adds the session to provenance if it is new, then recomputes confidence
294    /// and re-applies the validation gate — the multi-source bonus (≥2 sessions)
295    /// can lift a single-session `Pending` fact above the accept threshold, at
296    /// which point the status is promoted to `Accepted`. Returns `true` iff the
297    /// provenance grew.
298    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    /// Union provenance and conflict flags from `other`.
311    ///
312    /// Used by the merge path; the caller is responsible for reclassifying
313    /// afterwards. Self-references and duplicates are skipped.
314    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    /// Mark this fact as conflicting with `other_id` (one side of a bidirectional
325    /// flag; caller flags the other side). Rejects self-conflicts.
326    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    /// Rewarm the fact after a retrieval hit (§7 boost).
337    pub fn boost_heat(&mut self, now: Timestamp) {
338        self.heat_base = Heat::MAX;
339        self.last_access_at = now;
340    }
341
342    /// Set the validity tombstone (`valid_until`).
343    ///
344    /// Returns [`DomainError::ValidUntilBeforeValidFrom`] if `until` is at or
345    /// before `valid_from`. `None` clears a previously-set tombstone (fact
346    /// becomes current again).
347    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    /// Set status and confidence together, enforcing all transition invariants.
361    ///
362    /// Rules:
363    /// 1. Outgoing transitions from a terminal state (`Accepted`/`Rejected`)
364    ///    are illegal — only self-refresh is allowed.
365    /// 2. Accepted implies `confidence >= cfg.accept_threshold`.
366    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    // Read-only accessors. No public setters: every mutation goes through a
390    // method that enforces an invariant.
391
392    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    /// Builder-style constructor combinator: replace the embedding.
449    ///
450    /// Exposed because the persistence adapter rebuilds a `Fact` from markdown
451    /// frontmatter and may need to attach (or detach) the in-memory embedding
452    /// after the fact. This is the *only* way to mutate the embedding after
453    /// construction — all other fields stay invariant.
454    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        // No NLI, single source: base 0.5 → Pending.
652        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        // 0.5 base + 0.2 multi_source = 0.7 → Accepted.
678        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        // Persistence adapters call `rehydrate` on read; this test pins the
799        // round-trip contract: every persisted field must survive unchanged.
800        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    // -----------------------------------------------------------------
897    // compute_confidence — multi-source + NLI bonus formula
898    // -----------------------------------------------------------------
899
900    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    // -----------------------------------------------------------------
979    // heat_live — exponential decay
980    // -----------------------------------------------------------------
981
982    #[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        // exp(-0.03 * 24) ≈ 0.4868
992        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    // -----------------------------------------------------------------
1008    // find_merge_candidates — cosine similarity scan
1009    // -----------------------------------------------------------------
1010
1011    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        // Orthogonal → cosine 0.0 < 0.85.
1037        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        // Only "perfect" passes the default 0.85 threshold.
1055        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        // Same content → same id → excluded as a self-match.
1064        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    // -----------------------------------------------------------------
1097    // flag_conflict_bidirectional — symmetric conflict flag
1098    // -----------------------------------------------------------------
1099
1100    #[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}