use chrono::{DateTime, Utc};
use mempill_types::{
Belief, BeliefProjection, BeliefStatus, ClaimRef, CurrencySignal, CurrencyState, Disposition,
LedgerEntry, LedgerEventKind, Marker, StalenessFlag,
};
use crate::config::EngineConfig;
use crate::engine::truth_engine::{claim_to_belief, fold_staleness, FoldResult};
pub(crate) fn compute_currency_state(
last_refreshed_at: DateTime<Utc>,
now: DateTime<Utc>,
config: &EngineConfig,
) -> CurrencyState {
let duration = now.signed_duration_since(last_refreshed_at);
let days = duration.num_seconds() as f64 / 86_400.0;
if days < config.aging_unconfirmed_threshold_days as f64 {
CurrencyState::Fresh
} else if days < config.decayed_threshold_days as f64 {
CurrencyState::AgingUnconfirmed
} else {
CurrencyState::Decayed
}
}
pub(crate) fn build_currency_signal(
belief: &Belief,
now: DateTime<Utc>,
config: &EngineConfig,
) -> CurrencySignal {
let state = compute_currency_state(belief.transaction_time.0, now, config);
CurrencySignal {
last_refreshed_at: belief.transaction_time.clone(),
state,
corroboration_count: belief.currency_signal.corroboration_count,
}
}
pub(crate) fn compute_staleness(
primary: Option<&Belief>,
fold: &FoldResult,
now: DateTime<Utc>,
config: &EngineConfig,
) -> StalenessFlag {
if fold.live_claims.is_empty() {
return StalenessFlag { is_stale: true, reason: Some("no live claim".into()) };
}
if let Some(b) = primary {
let state = compute_currency_state(b.transaction_time.0, now, config);
match state {
CurrencyState::Decayed => StalenessFlag {
is_stale: true,
reason: Some(format!(
"currency decayed: last refreshed at {}",
b.transaction_time.0.to_rfc3339()
)),
},
CurrencyState::AgingUnconfirmed => StalenessFlag {
is_stale: false,
reason: Some("aging unconfirmed: asserted long ago, not yet reconfirmed".into()),
},
CurrencyState::Fresh => fold_staleness(fold),
_ => fold_staleness(fold),
}
} else {
fold_staleness(fold)
}
}
pub(crate) fn pending_review_refs(ledger_entries: &[LedgerEntry]) -> Vec<ClaimRef> {
ledger_entries
.iter()
.filter(|e| e.event_kind == LedgerEventKind::DependentFlaggedPendingReview
&& e.disposition == Disposition::PendingReview)
.map(|e| e.claim_ref.clone())
.collect()
}
pub(crate) fn is_pending_review(claim_ref: &ClaimRef, pending_refs: &[ClaimRef]) -> bool {
pending_refs.iter().any(|r| r == claim_ref)
}
pub(crate) fn build_markers(
fold: &FoldResult,
pending_review_claim_refs: &[ClaimRef],
contested: bool,
config: &EngineConfig,
) -> Vec<Marker> {
let mut markers = Vec::new();
if contested || fold.has_conflict {
markers.push(Marker::Contested);
}
let any_pending = fold.live_claims.iter().any(|cs| {
is_pending_review(cs.claim.claim_ref(), pending_review_claim_refs)
});
if any_pending {
markers.push(Marker::PendingReview);
}
let any_recall = fold.live_claims.iter().any(|cs| {
cs.claim.provenance().is_recall_reentry()
});
if any_recall {
markers.push(Marker::RecallTainted);
}
let any_low_anchor = fold.live_claims.iter().any(|cs| {
cs.claim.external_anchor().derivation_depth > config.derivation_depth_cap_for_currency_boost
});
if any_low_anchor {
markers.push(Marker::LowDerivationAnchor);
}
markers
}
pub(crate) fn project(
fold: &FoldResult,
ledger_entries: &[LedgerEntry],
now: DateTime<Utc>,
config: &EngineConfig,
contested: bool,
) -> BeliefProjection {
let pending_refs = pending_review_refs(ledger_entries);
let live_beliefs: Vec<Belief> = fold.live_claims.iter().map(|cs| {
let mut b = claim_to_belief(cs);
b.currency_signal = build_currency_signal(&b, now, config);
b
}).collect();
let (primary, alternatives) = if live_beliefs.is_empty() {
(None, vec![])
} else if live_beliefs.len() == 1 && !fold.has_conflict {
(Some(live_beliefs[0].clone()), vec![])
} else {
(None, live_beliefs.clone())
};
let primary_for_currency = primary.as_ref().or_else(|| live_beliefs.first());
let currency = primary_for_currency
.map(|b| build_currency_signal(b, now, config).state)
.unwrap_or(CurrencyState::Decayed);
let criticality = fold.live_claims.iter()
.map(|cs| cs.claim.criticality().clone())
.max()
.unwrap_or(mempill_types::Criticality::Low);
let staleness = compute_staleness(primary.as_ref(), fold, now, config);
let markers = build_markers(fold, &pending_refs, contested, config);
let status = if live_beliefs.is_empty() {
BeliefStatus::NoBelief
} else if contested || fold.has_conflict {
BeliefStatus::Contested
} else if live_beliefs.len() == 1 {
let c = &fold.live_claims[0].claim;
if c.valid_time().is_unknown() {
BeliefStatus::TimingUncertain
} else {
BeliefStatus::Resolved
}
} else {
BeliefStatus::Resolved };
BeliefProjection {
status,
primary,
alternatives,
currency,
criticality,
staleness,
markers,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::EngineConfig;
use crate::engine::truth_engine::fold;
use crate::ports::persistence::PersistencePort;
use crate::ports::persistence::Txn;
use chrono::Utc;
use std::collections::HashMap;
use mempill_types::{
AgentId, Cardinality, Claim, ClaimEdge, ClaimRef, Confidence, Disposition,
ExternalAnchor, ExternalKind, Fact, LedgerEntry, LedgerEventKind, ProvenanceLabel,
TransactionTime, ValidTime, ValidityAssertion,
};
fn agent() -> AgentId {
AgentId("agent-proj".into())
}
fn make_claim(
agent_id: &AgentId,
value: serde_json::Value,
tx_time: DateTime<Utc>,
vt_start: Option<DateTime<Utc>>,
vt_confidence: f32,
) -> Claim {
Claim::new(
ClaimRef::new_random(),
agent_id.clone(),
Fact { subject: "user".into(), predicate: "city".into(), value },
Cardinality::Functional,
ProvenanceLabel::External(ExternalKind::UserAsserted),
ExternalAnchor { nearest_external_anchor: None, derivation_depth: 0 },
TransactionTime(tx_time),
ValidTime { start: vt_start, end: None, valid_time_confidence: vt_confidence },
Confidence { value_confidence: 0.9, valid_time_confidence: vt_confidence },
mempill_types::Criticality::Medium,
vec![],
None,
None,
)
}
fn no_assertions(_: &ClaimRef) -> Vec<ValidityAssertion> {
vec![]
}
fn no_dispositions() -> HashMap<ClaimRef, Disposition> {
HashMap::new()
}
fn now() -> DateTime<Utc> {
Utc::now()
}
struct MockTxn(AgentId);
impl Txn for MockTxn {
fn agent_id(&self) -> &AgentId { &self.0 }
}
#[derive(Debug, thiserror::Error)]
#[error("mock error")]
struct MockErr;
struct MockPort {
ledger: Vec<LedgerEntry>,
}
impl PersistencePort for MockPort {
type Transaction = MockTxn;
type Error = MockErr;
fn begin_atomic(&self, a: &AgentId) -> Result<MockTxn, MockErr> { Ok(MockTxn(a.clone())) }
fn append_claim(&self, _: &mut MockTxn, _: &Claim) -> Result<ClaimRef, MockErr> { unimplemented!() }
fn append_validity_assertion(&self, _: &mut MockTxn, _: &ValidityAssertion) -> Result<(), MockErr> { unimplemented!() }
fn append_ledger_entry(&self, _: &mut MockTxn, _: &LedgerEntry) -> Result<(), MockErr> { unimplemented!() }
fn append_claim_edge(&self, _: &mut MockTxn, _: &ClaimEdge) -> Result<(), MockErr> { unimplemented!() }
fn commit(&self, _: MockTxn) -> Result<(), MockErr> { Ok(()) }
fn rollback(&self, _: MockTxn) -> Result<(), MockErr> { Ok(()) }
fn load_subject_line(&self, _: &AgentId, _: &str, _: &str) -> Result<Vec<Claim>, MockErr> { Ok(vec![]) }
fn load_claim(&self, _: &AgentId, _: &ClaimRef) -> Result<Option<Claim>, MockErr> { Ok(None) }
fn load_validity_assertions_for(&self, _: &AgentId, _: &ClaimRef) -> Result<Vec<ValidityAssertion>, MockErr> { Ok(vec![]) }
fn load_ledger(&self, _: &AgentId, _: Option<&TransactionTime>, _: usize) -> Result<Vec<LedgerEntry>, MockErr> {
Ok(self.ledger.clone())
}
fn load_ledger_for_claims(&self, _: &AgentId, _refs: &[ClaimRef]) -> Result<Vec<LedgerEntry>, MockErr> { Ok(vec![]) }
fn load_edges_for(&self, _: &AgentId, _: &ClaimRef) -> Result<Vec<ClaimEdge>, MockErr> { Ok(vec![]) }
fn load_injected_claims(&self, _: &AgentId) -> Result<Vec<ClaimRef>, MockErr> { Ok(vec![]) }
fn load_lineage(&self, _: &AgentId, _: &ClaimRef) -> Result<Vec<ClaimEdge>, MockErr> { Ok(vec![]) }
}
#[test]
fn currency_decay_decayed_threshold_reached() {
let config = EngineConfig::default(); let agent = agent();
let old_tx = Utc::now() - chrono::Duration::days(100); let query_now = now();
let claim = make_claim(&agent, serde_json::json!("Paris"), old_tx, None, 0.0);
let fold_result = fold(vec![claim.clone()], no_assertions, query_now, &config, &no_dispositions());
let projection = project(&fold_result, &[], query_now, &config, false);
assert_eq!(projection.currency, CurrencyState::Decayed, "should be Decayed after 100 days");
assert!(projection.primary.is_some() || !projection.alternatives.is_empty(),
"I11: decayed claim must still be present in projection (never deleted)");
}
#[test]
fn currency_decay_aging_unconfirmed() {
let config = EngineConfig::default();
let agent = agent();
let tx = Utc::now() - chrono::Duration::days(45); let query_now = now();
let claim = make_claim(&agent, serde_json::json!("Rome"), tx, None, 0.0);
let fold_result = fold(vec![claim], no_assertions, query_now, &config, &no_dispositions());
let projection = project(&fold_result, &[], query_now, &config, false);
assert_eq!(projection.currency, CurrencyState::AgingUnconfirmed, "should be AgingUnconfirmed");
}
#[test]
fn currency_decay_fresh() {
let config = EngineConfig::default();
let agent = agent();
let tx = Utc::now() - chrono::Duration::days(10);
let query_now = now();
let claim = make_claim(&agent, serde_json::json!("Berlin"), tx, None, 0.0);
let fold_result = fold(vec![claim], no_assertions, query_now, &config, &no_dispositions());
let projection = project(&fold_result, &[], query_now, &config, false);
assert_eq!(projection.currency, CurrencyState::Fresh, "should be Fresh within 30 days");
}
#[test]
fn explicit_invalidation_via_bound_assertion_removes_from_live() {
use mempill_types::AssertionKind;
let config = EngineConfig::default();
let agent = agent();
let tx = Utc::now() - chrono::Duration::days(1);
let bound_at = Utc::now() - chrono::Duration::hours(1);
let query_now = now();
let claim = make_claim(&agent, serde_json::json!("London"), tx, None, 0.0);
let claim_ref = claim.claim_ref().clone();
let bound_assertion = ValidityAssertion {
assertion_ref: uuid::Uuid::new_v4(),
agent_id: agent.clone(),
target_claim: claim_ref.clone(),
kind: AssertionKind::Bound { bound_at },
provenance: ProvenanceLabel::External(ExternalKind::UserAsserted),
confidence: Confidence { value_confidence: 1.0, valid_time_confidence: 1.0 },
asserted_at: TransactionTime(bound_at),
};
let bound_ref = claim_ref.clone();
let assertions_fn = move |cr: &ClaimRef| -> Vec<ValidityAssertion> {
if *cr == bound_ref {
vec![bound_assertion.clone()]
} else {
vec![]
}
};
let fold_result = fold(vec![claim], assertions_fn, query_now, &config, &no_dispositions());
let projection = project(&fold_result, &[], query_now, &config, false);
assert!(projection.primary.is_none(), "invalidated claim must not be the primary belief");
assert!(projection.alternatives.is_empty(), "invalidated claim must not be in alternatives");
assert_eq!(projection.status, BeliefStatus::NoBelief, "NoBelief after invalidation");
}
#[test]
fn contested_fold_conflict_surfaces_contested_status() {
let config = EngineConfig::default();
let agent = agent();
let t1 = Utc::now() - chrono::Duration::hours(5);
let t2 = Utc::now() - chrono::Duration::hours(1);
let c1 = make_claim(&agent, serde_json::json!("Paris"), t1, None, 0.0);
let c2 = make_claim(&agent, serde_json::json!("Rome"), t2, None, 0.0);
let fold_result = fold(vec![c1, c2], no_assertions, now(), &config, &no_dispositions());
let projection = project(&fold_result, &[], now(), &config, false);
assert_eq!(projection.status, BeliefStatus::Contested, "I7: conflict must surface as Contested");
assert!(projection.primary.is_none(), "I7: no silent primary when contested");
assert_eq!(projection.alternatives.len(), 2, "both contested claims in alternatives");
assert!(projection.markers.contains(&Marker::Contested), "Contested marker must be present");
}
#[test]
fn pending_review_a26_ledger_entry_surfaces_marker() {
let config = EngineConfig::default();
let agent = agent();
let tx = Utc::now() - chrono::Duration::days(1);
let query_now = now();
let claim = make_claim(&agent, serde_json::json!("Madrid"), tx, None, 0.0);
let claim_ref = claim.claim_ref().clone();
let fold_result = fold(vec![claim], no_assertions, query_now, &config, &no_dispositions());
let pending_entry = LedgerEntry {
entry_id: uuid::Uuid::new_v4(),
agent_id: agent.clone(),
claim_ref: claim_ref.clone(),
event_kind: LedgerEventKind::DependentFlaggedPendingReview,
disposition: Disposition::PendingReview,
rationale: None,
recorded_at: TransactionTime(tx),
};
let projection = project(&fold_result, &[pending_entry], query_now, &config, false);
assert!(
projection.markers.contains(&Marker::PendingReview),
"A26: PendingReview marker must appear when DependentFlaggedPendingReview ledger entry exists"
);
}
#[test]
fn now_injection_different_now_different_currency_state() {
let config = EngineConfig::default();
let agent = agent();
let tx = Utc::now() - chrono::Duration::days(91);
let claim = make_claim(&agent, serde_json::json!("Lisbon"), tx, None, 0.0);
let now_a = Utc::now();
let disp = no_dispositions();
let fold_a = fold(vec![claim.clone()], no_assertions, now_a, &config, &disp);
let proj_a = project(&fold_a, &[], now_a, &config, false);
let now_b = Utc::now() - chrono::Duration::days(50);
let fold_b = fold(vec![claim], no_assertions, now_b, &config, &disp);
let proj_b = project(&fold_b, &[], now_b, &config, false);
assert_eq!(proj_a.currency, CurrencyState::Decayed, "now_a: should be Decayed");
assert_eq!(proj_b.currency, CurrencyState::AgingUnconfirmed, "now_b: should be AgingUnconfirmed");
assert_ne!(proj_a.currency, proj_b.currency, "different now values must yield different currency state");
}
#[test]
fn no_belief_for_empty_subject_line() {
let config = EngineConfig::default();
let fold_result = fold(vec![], no_assertions, now(), &config, &no_dispositions());
let projection = project(&fold_result, &[], now(), &config, false);
assert_eq!(projection.status, BeliefStatus::NoBelief);
assert!(projection.primary.is_none());
assert!(projection.alternatives.is_empty());
assert!(projection.staleness.is_stale);
}
}