use crate::config::{ConfidenceConfig, MergeConfig};
use crate::enums::{FactStatus, FactType, NliLabel};
use crate::error::DomainError;
use crate::value_objects::{
Confidence, Cosine, Embedding, FactContent, FactId, Heat, MemoryKey, NliResult, SessionId,
SourceSessions, Timestamp,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Fact {
id: FactId,
memory_key: MemoryKey,
content: FactContent,
fact_type: FactType,
confidence: Confidence,
status: FactStatus,
valid_from: Timestamp,
valid_until: Option<Timestamp>,
extracted_at: Timestamp,
source_sessions: SourceSessions,
conflicts_with: Vec<FactId>,
heat_base: Heat,
last_access_at: Timestamp,
embedding: Option<Embedding>,
}
#[derive(Debug, Clone)]
pub struct MergeCandidate {
pub fact: Fact,
pub cosine_similarity: Cosine,
}
#[derive(Debug, Clone)]
pub struct FactRecord {
pub id: FactId,
pub memory_key: MemoryKey,
pub content: FactContent,
pub fact_type: FactType,
pub confidence: Confidence,
pub status: FactStatus,
pub valid_from: Timestamp,
pub valid_until: Option<Timestamp>,
pub extracted_at: Timestamp,
pub source_sessions: SourceSessions,
pub conflicts_with: Vec<FactId>,
pub heat_base: Heat,
pub last_access_at: Timestamp,
pub embedding: Option<Embedding>,
}
#[derive(Debug)]
pub struct NewPendingRequest<'a> {
pub content: &'a str,
pub memory_key: MemoryKey,
pub session: SessionId,
pub embedding: Embedding,
pub extracted_at: Timestamp,
pub base_confidence: f32,
}
impl Fact {
pub fn new_pending(req: NewPendingRequest<'_>) -> Result<Self, DomainError> {
Ok(Self {
id: FactId::from_content(req.content),
memory_key: req.memory_key,
content: FactContent::new(req.content.to_string())?,
fact_type: FactType::Entity,
confidence: Confidence::new(req.base_confidence)?,
status: FactStatus::Pending,
valid_from: req.extracted_at,
valid_until: None,
extracted_at: req.extracted_at,
source_sessions: SourceSessions::from_one(req.session),
conflicts_with: Vec::new(),
heat_base: Heat::new(1.0)?,
last_access_at: req.extracted_at,
embedding: Some(req.embedding),
})
}
pub fn rehydrate(rec: FactRecord) -> Result<Self, DomainError> {
if rec.id != FactId::from_content(rec.content.as_str()) {
return Err(DomainError::InvalidFactId(format!(
"rehydrate id mismatch: record={} expected={}",
rec.id,
FactId::from_content(rec.content.as_str())
)));
}
if let Some(until) = rec.valid_until
&& until <= rec.valid_from
{
return Err(DomainError::ValidUntilBeforeValidFrom {
from: rec.valid_from,
until,
});
}
Ok(Self {
id: rec.id,
memory_key: rec.memory_key,
content: rec.content,
fact_type: rec.fact_type,
confidence: rec.confidence,
status: rec.status,
valid_from: rec.valid_from,
valid_until: rec.valid_until,
extracted_at: rec.extracted_at,
source_sessions: rec.source_sessions,
conflicts_with: rec.conflicts_with,
heat_base: rec.heat_base,
last_access_at: rec.last_access_at,
embedding: rec.embedding,
})
}
pub fn reclassify(
&mut self,
nli: Option<&NliResult>,
cfg: &ConfidenceConfig,
) -> Result<(), DomainError> {
let new_conf = self.compute_confidence(nli, cfg);
let new_status = new_conf.classify(cfg);
self.set_status_and_confidence(new_status, new_conf, cfg)
}
pub fn compute_confidence(
&self,
nli: Option<&NliResult>,
cfg: &ConfidenceConfig,
) -> Confidence {
let mut score = cfg.base;
if self.source_sessions.distinct_count() >= 2 {
score += cfg.multi_source_bonus;
}
if let Some(nli) = nli
&& nli.available
&& nli.label != NliLabel::Contradiction
{
score += cfg.no_contradiction_bonus;
}
Confidence::new_unchecked(score)
}
pub fn heat_live(&self, now: Timestamp, decay_rate: f32) -> f32 {
Heat::decay(self.heat_base, self.last_access_at, now, decay_rate)
}
pub fn find_merge_candidates(&self, pool: &[Fact], cfg: &MergeConfig) -> Vec<MergeCandidate> {
let Some(self_emb) = self.embedding.as_ref() else {
return Vec::new();
};
let self_key = &self.memory_key;
let self_id = &self.id;
let mut candidates: Vec<MergeCandidate> = pool
.iter()
.filter(|f| &f.memory_key == self_key)
.filter(|f| &f.id != self_id)
.filter_map(|f| {
let emb = f.embedding()?;
let sim = self_emb.cosine(emb);
if sim.value() >= cfg.cosine_threshold {
Some(MergeCandidate {
fact: f.clone(),
cosine_similarity: sim,
})
} else {
None
}
})
.collect();
candidates.sort_by(|a, b| {
b.cosine_similarity
.value()
.partial_cmp(&a.cosine_similarity.value())
.unwrap_or(std::cmp::Ordering::Equal)
});
candidates
}
pub fn flag_conflict_bidirectional(&mut self, other: &mut Fact) -> Result<(), DomainError> {
let self_id = self.id.clone();
let other_id = other.id.clone();
self.flag_conflict(other_id)?;
other.flag_conflict(self_id)?;
Ok(())
}
pub fn confirm_cross_session(
&mut self,
session: &SessionId,
cfg: &ConfidenceConfig,
) -> Result<bool, DomainError> {
let grew = self.source_sessions.add_unique(session.clone());
if grew {
self.reclassify(None, cfg)?;
}
Ok(grew)
}
pub fn merge_into(&mut self, other: &Fact) -> Result<(), DomainError> {
self.source_sessions.union(&other.source_sessions);
for cid in &other.conflicts_with {
if *cid != self.id && !self.conflicts_with.contains(cid) {
self.conflicts_with.push(cid.clone());
}
}
Ok(())
}
pub fn flag_conflict(&mut self, other_id: FactId) -> Result<(), DomainError> {
if other_id == self.id {
return Err(DomainError::SelfConflict(self.id.clone()));
}
if !self.conflicts_with.contains(&other_id) {
self.conflicts_with.push(other_id);
}
Ok(())
}
pub fn boost_heat(&mut self, now: Timestamp) {
self.heat_base = Heat::MAX;
self.last_access_at = now;
}
pub fn set_valid_until(&mut self, until: Option<Timestamp>) -> Result<(), DomainError> {
if let Some(ts) = until
&& ts <= self.valid_from
{
return Err(DomainError::ValidUntilBeforeValidFrom {
from: self.valid_from,
until: ts,
});
}
self.valid_until = until;
Ok(())
}
pub fn set_status_and_confidence(
&mut self,
status: FactStatus,
conf: Confidence,
cfg: &ConfidenceConfig,
) -> Result<(), DomainError> {
if self.status.is_terminal() && status != self.status {
return Err(DomainError::IllegalStatusTransition {
from: self.status,
to: status,
});
}
if status == FactStatus::Accepted && conf.value() < cfg.accept_threshold {
return Err(DomainError::ConfidenceBelowAcceptThreshold {
threshold: cfg.accept_threshold,
actual: conf.value(),
});
}
self.status = status;
self.confidence = conf;
Ok(())
}
pub fn id(&self) -> &FactId {
&self.id
}
pub fn memory_key(&self) -> &MemoryKey {
&self.memory_key
}
pub fn content(&self) -> &str {
self.content.as_str()
}
pub fn fact_type(&self) -> FactType {
self.fact_type
}
pub fn confidence(&self) -> Confidence {
self.confidence
}
pub fn status(&self) -> FactStatus {
self.status
}
pub fn valid_from(&self) -> Timestamp {
self.valid_from
}
pub fn valid_until(&self) -> Option<Timestamp> {
self.valid_until
}
pub fn extracted_at(&self) -> Timestamp {
self.extracted_at
}
pub fn source_sessions(&self) -> &SourceSessions {
&self.source_sessions
}
pub fn conflicts_with(&self) -> &[FactId] {
&self.conflicts_with
}
pub fn heat_base(&self) -> Heat {
self.heat_base
}
pub fn last_access_at(&self) -> Timestamp {
self.last_access_at
}
pub fn embedding(&self) -> Option<&Embedding> {
self.embedding.as_ref()
}
pub fn with_embedding(mut self, embedding: Option<Embedding>) -> Self {
self.embedding = embedding;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value_objects::SessionId;
fn sid(suffix: u8) -> SessionId {
let hex = format!("sess_{:012x}", suffix as u64);
SessionId::from_raw(&hex).unwrap()
}
fn emb(dim: usize) -> Embedding {
Embedding::new((0..dim).map(|i| i as f32 + 1.0).collect()).unwrap()
}
fn pending_fact(content: &str, session: SessionId) -> Fact {
Fact::new_pending(NewPendingRequest {
content,
memory_key: MemoryKey::from_raw("origa").unwrap(),
session,
embedding: emb(8),
extracted_at: Timestamp::from_unix_secs(1_700_000_000).unwrap(),
base_confidence: ConfidenceConfig::default().base,
})
.unwrap()
}
fn default_cfg() -> ConfidenceConfig {
ConfidenceConfig::default()
}
#[test]
fn new_pending_initialises_all_fields() {
let session = sid(1);
let fact = pending_fact("Rust is fast", session.clone());
assert_eq!(fact.content(), "Rust is fast");
assert_eq!(fact.memory_key().as_str(), "origa");
assert_eq!(fact.fact_type(), FactType::Entity);
assert_eq!(fact.confidence().value(), 0.5);
assert_eq!(fact.status(), FactStatus::Pending);
assert!(fact.valid_until().is_none());
assert_eq!(fact.source_sessions().distinct_count(), 1);
assert!(fact.conflicts_with().is_empty());
assert_eq!(fact.heat_base().value(), 1.0);
assert!(fact.embedding().is_some());
assert_eq!(fact.id(), &FactId::from_content("Rust is fast"));
}
#[test]
fn new_pending_rejects_empty_content() {
let err = Fact::new_pending(NewPendingRequest {
content: " ",
memory_key: MemoryKey::shared(),
session: sid(1),
embedding: emb(4),
extracted_at: Timestamp::from_unix_secs(0).unwrap(),
base_confidence: ConfidenceConfig::default().base,
})
.unwrap_err();
assert!(matches!(err, DomainError::EmptyFactContent));
}
#[test]
fn set_status_pending_to_accepted_when_confidence_is_high_enough() {
let mut fact = pending_fact("a", sid(1));
fact.set_status_and_confidence(
FactStatus::Accepted,
Confidence::new(0.7).unwrap(),
&default_cfg(),
)
.unwrap();
assert_eq!(fact.status(), FactStatus::Accepted);
}
#[test]
fn set_status_pending_to_accepted_rejects_low_confidence() {
let mut fact = pending_fact("a", sid(1));
let err = fact
.set_status_and_confidence(
FactStatus::Accepted,
Confidence::new(0.5).unwrap(),
&default_cfg(),
)
.unwrap_err();
assert!(matches!(
err,
DomainError::ConfidenceBelowAcceptThreshold {
threshold: 0.7,
actual: 0.5
}
));
}
#[test]
fn set_status_pending_to_rejected_is_allowed() {
let mut fact = pending_fact("a", sid(1));
fact.set_status_and_confidence(
FactStatus::Rejected,
Confidence::new(0.0).unwrap(),
&default_cfg(),
)
.unwrap();
assert_eq!(fact.status(), FactStatus::Rejected);
}
#[test]
fn set_status_accepted_to_accepted_is_allowed_for_refresh() {
let mut fact = pending_fact("a", sid(1));
fact.set_status_and_confidence(
FactStatus::Accepted,
Confidence::new(0.9).unwrap(),
&default_cfg(),
)
.unwrap();
fact.set_status_and_confidence(
FactStatus::Accepted,
Confidence::new(0.95).unwrap(),
&default_cfg(),
)
.unwrap();
assert_eq!(fact.confidence().value(), 0.95);
}
#[test]
fn set_status_accepted_to_pending_is_illegal() {
let mut fact = pending_fact("a", sid(1));
fact.set_status_and_confidence(
FactStatus::Accepted,
Confidence::new(0.9).unwrap(),
&default_cfg(),
)
.unwrap();
let err = fact
.set_status_and_confidence(
FactStatus::Pending,
Confidence::new(0.5).unwrap(),
&default_cfg(),
)
.unwrap_err();
assert!(matches!(
err,
DomainError::IllegalStatusTransition {
from: FactStatus::Accepted,
to: FactStatus::Pending
}
));
}
#[test]
fn set_status_accepted_to_rejected_is_illegal() {
let mut fact = pending_fact("a", sid(1));
fact.set_status_and_confidence(
FactStatus::Accepted,
Confidence::new(0.9).unwrap(),
&default_cfg(),
)
.unwrap();
assert!(
fact.set_status_and_confidence(
FactStatus::Rejected,
Confidence::new(0.0).unwrap(),
&default_cfg(),
)
.is_err()
);
}
#[test]
fn set_status_rejected_to_anything_is_illegal() {
let mut fact = pending_fact("a", sid(1));
fact.set_status_and_confidence(
FactStatus::Rejected,
Confidence::new(0.0).unwrap(),
&default_cfg(),
)
.unwrap();
for target in [FactStatus::Pending, FactStatus::Accepted] {
assert!(
fact.set_status_and_confidence(
target,
Confidence::new(0.5).unwrap(),
&default_cfg()
)
.is_err()
);
}
}
#[test]
fn reclassify_applies_confidence_and_status_atomically() {
let mut fact = pending_fact("a", sid(1));
fact.reclassify(None, &default_cfg()).unwrap();
assert_eq!(fact.confidence().value(), 0.5);
assert_eq!(fact.status(), FactStatus::Pending);
}
#[test]
fn confirm_cross_session_adds_session_first_time() {
let mut fact = pending_fact("a", sid(1));
let grew = fact.confirm_cross_session(&sid(2), &default_cfg()).unwrap();
assert!(grew);
assert_eq!(fact.source_sessions().distinct_count(), 2);
}
#[test]
fn confirm_cross_session_returns_false_on_repeat() {
let mut fact = pending_fact("a", sid(1));
fact.confirm_cross_session(&sid(2), &default_cfg()).unwrap();
let grew = fact.confirm_cross_session(&sid(2), &default_cfg()).unwrap();
assert!(!grew);
}
#[test]
fn confirm_cross_session_lifts_confidence_to_accept_threshold() {
let mut fact = pending_fact("a", sid(1));
fact.confirm_cross_session(&sid(2), &default_cfg()).unwrap();
assert!((fact.confidence().value() - 0.7).abs() < 1e-6);
assert_eq!(fact.status(), FactStatus::Accepted);
}
#[test]
fn merge_into_unions_source_sessions() {
let mut left = pending_fact("a", sid(1));
let mut right = pending_fact("a", sid(2));
right
.confirm_cross_session(&sid(3), &default_cfg())
.unwrap();
left.merge_into(&right).unwrap();
assert_eq!(left.source_sessions().distinct_count(), 3);
}
#[test]
fn merge_into_unions_conflicts_without_self_reference() {
let mut left = pending_fact("a", sid(1));
let other_id = FactId::from_content("other");
left.flag_conflict(other_id.clone()).unwrap();
let right = pending_fact("a", sid(2));
left.merge_into(&right).unwrap();
assert!(left.conflicts_with().contains(&other_id));
}
#[test]
fn merge_into_dedups_conflict_flags() {
let mut left = pending_fact("a", sid(1));
let other_id = FactId::from_content("other");
left.flag_conflict(other_id.clone()).unwrap();
let mut right = pending_fact("a", sid(2));
right.flag_conflict(other_id.clone()).unwrap();
left.merge_into(&right).unwrap();
let count = left
.conflicts_with()
.iter()
.filter(|id| **id == other_id)
.count();
assert_eq!(count, 1);
}
#[test]
fn flag_conflict_rejects_self_conflict() {
let mut fact = pending_fact("a", sid(1));
let err = fact.flag_conflict(fact.id().clone()).unwrap_err();
assert!(matches!(err, DomainError::SelfConflict(_)));
}
#[test]
fn flag_conflict_is_idempotent() {
let mut fact = pending_fact("a", sid(1));
let other = FactId::from_content("other");
fact.flag_conflict(other.clone()).unwrap();
fact.flag_conflict(other.clone()).unwrap();
assert_eq!(
fact.conflicts_with()
.iter()
.filter(|id| **id == other)
.count(),
1
);
}
#[test]
fn boost_heat_sets_max_heat_and_refreshes_access_time() {
let mut fact = pending_fact("a", sid(1));
let now = Timestamp::from_unix_secs(1_800_000_000).unwrap();
fact.boost_heat(now);
assert_eq!(fact.heat_base().value(), 1.0);
assert_eq!(fact.last_access_at().as_unix_secs(), 1_800_000_000);
}
#[test]
fn set_valid_until_accepts_timestamp_strictly_after_valid_from() {
let mut fact = pending_fact("a", sid(1));
let original_valid_from = fact.valid_from();
let later = Timestamp::from_unix_secs(original_valid_from.as_unix_secs() + 3600).unwrap();
fact.set_valid_until(Some(later)).unwrap();
assert_eq!(fact.valid_until(), Some(later));
}
#[test]
fn set_valid_until_rejects_timestamp_at_or_before_valid_from() {
let mut fact = pending_fact("a", sid(1));
let original_valid_from = fact.valid_from();
let equal = original_valid_from;
let earlier = Timestamp::from_unix_secs(original_valid_from.as_unix_secs() - 10).unwrap();
assert!(fact.set_valid_until(Some(equal)).is_err());
assert!(fact.set_valid_until(Some(earlier)).is_err());
}
#[test]
fn set_valid_until_none_clears_tombstone() {
let mut fact = pending_fact("a", sid(1));
let original = fact.valid_from();
let later = Timestamp::from_unix_secs(original.as_unix_secs() + 3600).unwrap();
fact.set_valid_until(Some(later)).unwrap();
fact.set_valid_until(None).unwrap();
assert!(fact.valid_until().is_none());
}
#[test]
fn with_embedding_overrides_embedding() {
let fact = pending_fact("a", sid(1));
let replaced = fact.with_embedding(None);
assert!(replaced.embedding().is_none());
}
#[test]
fn serde_roundtrip_preserves_fact_fields() {
let fact = pending_fact("Rust fact", sid(1));
let json = serde_json::to_string(&fact).unwrap();
let back: Fact = serde_json::from_str(&json).unwrap();
assert_eq!(back.content(), "Rust fact");
assert_eq!(back.status(), FactStatus::Pending);
assert_eq!(back.confidence().value(), 0.5);
}
#[test]
fn rehydrate_roundtrips_every_field_verbatim() {
let mut fact = pending_fact("Rust fact", sid(1));
fact.set_status_and_confidence(
FactStatus::Accepted,
Confidence::new(0.92).unwrap(),
&default_cfg(),
)
.unwrap();
fact.flag_conflict(FactId::from_content("other")).unwrap();
fact.set_valid_until(Some(
Timestamp::from_unix_secs(fact.valid_from().as_unix_secs() + 3600).unwrap(),
))
.unwrap();
let rehydrated = Fact::rehydrate(FactRecord {
id: fact.id().clone(),
memory_key: fact.memory_key().clone(),
content: FactContent::new(fact.content().to_string()).unwrap(),
fact_type: fact.fact_type(),
confidence: fact.confidence(),
status: fact.status(),
valid_from: fact.valid_from(),
valid_until: fact.valid_until(),
extracted_at: fact.extracted_at(),
source_sessions: fact.source_sessions().clone(),
conflicts_with: fact.conflicts_with().to_vec(),
heat_base: fact.heat_base(),
last_access_at: fact.last_access_at(),
embedding: fact.embedding().cloned(),
})
.unwrap();
assert_eq!(rehydrated.id(), fact.id());
assert_eq!(rehydrated.content(), fact.content());
assert_eq!(rehydrated.fact_type(), fact.fact_type());
assert_eq!(rehydrated.confidence().value(), fact.confidence().value());
assert_eq!(rehydrated.status(), fact.status());
assert_eq!(rehydrated.valid_from(), fact.valid_from());
assert_eq!(rehydrated.valid_until(), fact.valid_until());
assert_eq!(rehydrated.extracted_at(), fact.extracted_at());
assert_eq!(
rehydrated.source_sessions().distinct_count(),
fact.source_sessions().distinct_count()
);
assert_eq!(rehydrated.conflicts_with(), fact.conflicts_with());
assert_eq!(rehydrated.heat_base().value(), fact.heat_base().value());
assert_eq!(rehydrated.last_access_at(), fact.last_access_at());
}
#[test]
fn rehydrate_rejects_id_that_disagrees_with_content() {
let fact = pending_fact("Rust fact", sid(1));
let wrong_id = FactId::from_content("different content");
let err = Fact::rehydrate(FactRecord {
id: wrong_id.clone(),
memory_key: fact.memory_key().clone(),
content: FactContent::new(fact.content().to_string()).unwrap(),
fact_type: fact.fact_type(),
confidence: fact.confidence(),
status: fact.status(),
valid_from: fact.valid_from(),
valid_until: None,
extracted_at: fact.extracted_at(),
source_sessions: fact.source_sessions().clone(),
conflicts_with: Vec::new(),
heat_base: fact.heat_base(),
last_access_at: fact.last_access_at(),
embedding: None,
})
.unwrap_err();
assert!(matches!(err, DomainError::InvalidFactId(_)));
}
#[test]
fn rehydrate_rejects_valid_until_at_or_before_valid_from() {
let fact = pending_fact("Rust fact", sid(1));
let at_valid_from = fact.valid_from();
let err = Fact::rehydrate(FactRecord {
id: fact.id().clone(),
memory_key: fact.memory_key().clone(),
content: FactContent::new(fact.content().to_string()).unwrap(),
fact_type: fact.fact_type(),
confidence: fact.confidence(),
status: fact.status(),
valid_from: fact.valid_from(),
valid_until: Some(at_valid_from),
extracted_at: fact.extracted_at(),
source_sessions: fact.source_sessions().clone(),
conflicts_with: Vec::new(),
heat_base: fact.heat_base(),
last_access_at: fact.last_access_at(),
embedding: None,
})
.unwrap_err();
assert!(matches!(err, DomainError::ValidUntilBeforeValidFrom { .. }));
}
fn nli_result(label: NliLabel, available: bool) -> NliResult {
use crate::value_objects::NliScores;
NliResult {
label,
scores: NliScores {
entailment: if label == NliLabel::Entailment {
1.0
} else {
0.0
},
neutral: if label == NliLabel::Neutral { 1.0 } else { 0.0 },
contradiction: if label == NliLabel::Contradiction {
1.0
} else {
0.0
},
},
available,
}
}
#[test]
fn compute_confidence_base_only_for_single_source_no_nli() {
let fact = pending_fact("a", sid(1));
let c = fact.compute_confidence(None, &default_cfg());
assert!((c.value() - 0.5).abs() < 1e-6);
}
#[test]
fn compute_confidence_multi_source_bonus_applies_with_two_sessions() {
let mut fact = pending_fact("a", sid(1));
fact.confirm_cross_session(&sid(2), &default_cfg()).unwrap();
let c = fact.compute_confidence(None, &default_cfg());
assert!((c.value() - 0.7).abs() < 1e-6);
}
#[test]
fn compute_confidence_no_contradiction_bonus_for_entailment() {
let fact = pending_fact("a", sid(1));
let c = fact.compute_confidence(
Some(&nli_result(NliLabel::Entailment, true)),
&default_cfg(),
);
assert!((c.value() - 0.6).abs() < 1e-6);
}
#[test]
fn compute_confidence_no_contradiction_bonus_skipped_for_contradiction() {
let fact = pending_fact("a", sid(1));
let c = fact.compute_confidence(
Some(&nli_result(NliLabel::Contradiction, true)),
&default_cfg(),
);
assert!((c.value() - 0.5).abs() < 1e-6);
}
#[test]
fn compute_confidence_no_contradiction_bonus_skipped_when_unavailable() {
let fact = pending_fact("a", sid(1));
let c = fact.compute_confidence(
Some(&nli_result(NliLabel::Entailment, false)),
&default_cfg(),
);
assert!((c.value() - 0.5).abs() < 1e-6);
}
#[test]
fn compute_confidence_both_bonuses_stack_and_clamp_at_one() {
let mut fact = pending_fact("a", sid(1));
fact.confirm_cross_session(&sid(2), &default_cfg()).unwrap();
fact.confirm_cross_session(&sid(3), &default_cfg()).unwrap();
let c = fact.compute_confidence(
Some(&nli_result(NliLabel::Entailment, true)),
&default_cfg(),
);
assert!((c.value() - 0.8).abs() < 1e-6);
}
#[test]
fn heat_live_fresh_fact_has_full_heat() {
let fact = pending_fact("a", sid(1));
let now = fact.last_access_at();
assert!((fact.heat_live(now, 0.03) - 1.0).abs() < 1e-6);
}
#[test]
fn heat_live_decays_after_24_hours_at_known_rate() {
let fact = pending_fact("a", sid(1));
let base = fact.last_access_at();
let one_day_later = Timestamp::from_unix_secs(base.as_unix_secs() + 24 * 3600).unwrap();
let h = fact.heat_live(one_day_later, 0.03);
assert!((h - 0.4868).abs() < 1e-3, "got {h}");
}
#[test]
fn heat_live_future_access_clamps_to_zero_decay() {
let fact = pending_fact("a", sid(1));
let base = fact.last_access_at();
let earlier = Timestamp::from_unix_secs(base.as_unix_secs() - 3600).unwrap();
assert!((fact.heat_live(earlier, 0.03) - 1.0).abs() < 1e-6);
}
fn fact_with_key_embedding(content: &str, key: &str, embedding: Vec<f32>) -> Fact {
Fact::new_pending(NewPendingRequest {
content,
memory_key: MemoryKey::from_raw(key).unwrap(),
session: sid(1),
embedding: Embedding::new(embedding).unwrap(),
extracted_at: Timestamp::from_unix_secs(0).unwrap(),
base_confidence: ConfidenceConfig::default().base,
})
.unwrap()
}
#[test]
fn find_merge_candidates_empty_pool_returns_empty() {
let pending = fact_with_key_embedding("p", "origa", vec![1.0, 0.0]);
assert!(
pending
.find_merge_candidates(&[], &MergeConfig::default())
.is_empty()
);
}
#[test]
fn find_merge_candidates_filters_below_threshold() {
let pending = fact_with_key_embedding("p", "origa", vec![1.0, 0.0]);
let pool = vec![fact_with_key_embedding("x", "origa", vec![0.0, 1.0])];
assert!(
pending
.find_merge_candidates(&pool, &MergeConfig::default())
.is_empty()
);
}
#[test]
fn find_merge_candidates_keeps_above_threshold_sorted_desc() {
let pending = fact_with_key_embedding("p", "origa", vec![1.0, 0.0]);
let pool = vec![
fact_with_key_embedding("ortho", "origa", vec![0.0, 1.0]),
fact_with_key_embedding("mid", "origa", vec![1.0, 1.0]),
fact_with_key_embedding("perfect", "origa", vec![1.0, 0.0]),
];
let out = pending.find_merge_candidates(&pool, &MergeConfig::default());
assert_eq!(out.len(), 1);
assert_eq!(out[0].fact.content(), "perfect");
assert!((out[0].cosine_similarity.value() - 1.0).abs() < 1e-5);
}
#[test]
fn find_merge_candidates_excludes_self_id() {
let pending = fact_with_key_embedding("same", "origa", vec![1.0, 0.0]);
let pool = vec![fact_with_key_embedding("same", "origa", vec![1.0, 0.0])];
assert!(
pending
.find_merge_candidates(&pool, &MergeConfig::default())
.is_empty()
);
}
#[test]
fn find_merge_candidates_excludes_different_memory_key() {
let pending = fact_with_key_embedding("p", "origa", vec![1.0, 0.0]);
let pool = vec![fact_with_key_embedding("x", "other", vec![1.0, 0.0])];
assert!(
pending
.find_merge_candidates(&pool, &MergeConfig::default())
.is_empty()
);
}
#[test]
fn find_merge_candidates_skips_pool_member_without_embedding() {
let pending = fact_with_key_embedding("p", "origa", vec![1.0, 0.0]);
let pool_member =
fact_with_key_embedding("x", "origa", vec![1.0, 0.0]).with_embedding(None);
let pool = vec![pool_member];
assert!(
pending
.find_merge_candidates(&pool, &MergeConfig::default())
.is_empty()
);
}
#[test]
fn flag_conflict_bidirectional_sets_both_sides() {
let mut a = pending_fact("alpha", sid(1));
let mut b = pending_fact("beta", sid(2));
a.flag_conflict_bidirectional(&mut b).unwrap();
assert!(a.conflicts_with().contains(b.id()));
assert!(b.conflicts_with().contains(a.id()));
}
#[test]
fn flag_conflict_bidirectional_is_idempotent() {
let mut a = pending_fact("alpha", sid(1));
let mut b = pending_fact("beta", sid(2));
a.flag_conflict_bidirectional(&mut b).unwrap();
a.flag_conflict_bidirectional(&mut b).unwrap();
assert_eq!(a.conflicts_with().len(), 1);
assert_eq!(b.conflicts_with().len(), 1);
}
}