use thiserror::Error;
use crate::confidence::Confidence;
use crate::memory_kind::MemoryKindTag;
use crate::source_kind::SourceKind;
#[rustfmt::skip]
const DECAY_TABLE: [u16; 256] = [
65535, 65358, 65181, 65005, 64829, 64654, 64479, 64305,
64131, 63957, 63784, 63612, 63440, 63268, 63097, 62927,
62757, 62587, 62418, 62249, 62081, 61913, 61745, 61578,
61412, 61246, 61080, 60915, 60750, 60586, 60422, 60259,
60096, 59933, 59771, 59610, 59449, 59288, 59127, 58968,
58808, 58649, 58491, 58332, 58175, 58017, 57860, 57704,
57548, 57392, 57237, 57082, 56928, 56774, 56621, 56468,
56315, 56163, 56011, 55859, 55708, 55558, 55407, 55258,
55108, 54959, 54811, 54662, 54515, 54367, 54220, 54074,
53927, 53781, 53636, 53491, 53346, 53202, 53058, 52915,
52772, 52629, 52487, 52345, 52203, 52062, 51921, 51781,
51641, 51501, 51362, 51223, 51085, 50947, 50809, 50671,
50534, 50398, 50261, 50126, 49990, 49855, 49720, 49586,
49452, 49318, 49184, 49051, 48919, 48787, 48655, 48523,
48392, 48261, 48131, 48000, 47871, 47741, 47612, 47483,
47355, 47227, 47099, 46972, 46845, 46718, 46592, 46466,
46340, 46215, 46090, 45965, 45841, 45717, 45593, 45470,
45347, 45225, 45102, 44980, 44859, 44737, 44617, 44496,
44376, 44256, 44136, 44017, 43898, 43779, 43660, 43542,
43425, 43307, 43190, 43073, 42957, 42841, 42725, 42609,
42494, 42379, 42265, 42150, 42036, 41923, 41809, 41696,
41584, 41471, 41359, 41247, 41136, 41024, 40914, 40803,
40693, 40583, 40473, 40363, 40254, 40145, 40037, 39929,
39821, 39713, 39606, 39498, 39392, 39285, 39179, 39073,
38967, 38862, 38757, 38652, 38548, 38443, 38339, 38236,
38132, 38029, 37926, 37824, 37722, 37620, 37518, 37416,
37315, 37214, 37114, 37013, 36913, 36813, 36714, 36615,
36516, 36417, 36318, 36220, 36122, 36025, 35927, 35830,
35733, 35637, 35540, 35444, 35348, 35253, 35157, 35062,
34968, 34873, 34779, 34685, 34591, 34497, 34404, 34311,
34218, 34126, 34033, 33941, 33850, 33758, 33667, 33576,
33485, 33394, 33304, 33214, 33124, 33035, 32945, 32856,
];
pub const DAY_MS: u64 = 86_400_000;
pub const NO_DECAY: u64 = 0;
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub struct HalfLife(u64);
impl HalfLife {
pub const ZERO: Self = Self(NO_DECAY);
#[must_use]
pub const fn from_days(days: u64) -> Self {
Self(days.saturating_mul(DAY_MS))
}
#[must_use]
pub const fn from_millis(millis: u64) -> Self {
Self(millis)
}
#[must_use]
pub const fn no_decay() -> Self {
Self(NO_DECAY)
}
#[must_use]
pub const fn as_millis(self) -> u64 {
self.0
}
#[must_use]
pub const fn is_no_decay(self) -> bool {
self.0 == NO_DECAY
}
}
const MAX_EXPONENT: u32 = 16;
const ELAPSED_CAP: u64 = u64::MAX / 256;
#[must_use]
pub fn decay_factor_u16(elapsed_ms: u64, half_life: HalfLife) -> u16 {
let half_life_ms = half_life.as_millis();
if half_life_ms == NO_DECAY {
return u16::MAX;
}
let elapsed = elapsed_ms.min(ELAPSED_CAP);
let k_q8 = (elapsed.saturating_mul(256)) / half_life_ms;
#[allow(clippy::cast_possible_truncation)]
let n = (k_q8 >> 8) as u32;
if n >= MAX_EXPONENT {
return 0;
}
let i = (k_q8 & 0xFF) as usize;
let frac = u32::from(DECAY_TABLE[i]);
#[allow(clippy::cast_possible_truncation)]
let result = (frac >> n) as u16;
result
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub struct DecayFlags {
pub pinned: bool,
pub authoritative: bool,
}
impl DecayFlags {
#[must_use]
pub const fn suspends_decay(self) -> bool {
self.pinned || self.authoritative
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DecayConfig {
pub sem_profile: HalfLife,
pub sem_observation: HalfLife,
pub sem_self_report: HalfLife,
pub sem_participant_report: HalfLife,
pub sem_document: HalfLife,
pub sem_registry: HalfLife,
pub sem_policy: HalfLife,
pub sem_external_authority: HalfLife,
pub sem_agent_instruction: HalfLife,
pub sem_librarian_assignment: HalfLife,
pub sem_pending_verification: HalfLife,
pub epi_observation: HalfLife,
pub epi_self_report: HalfLife,
pub epi_participant_report: HalfLife,
pub pro_any: HalfLife,
}
impl DecayConfig {
#[must_use]
pub const fn librarian_defaults() -> Self {
Self {
sem_profile: HalfLife::from_days(730),
sem_observation: HalfLife::from_days(180),
sem_self_report: HalfLife::from_days(90),
sem_participant_report: HalfLife::from_days(90),
sem_document: HalfLife::from_days(365),
sem_registry: HalfLife::from_days(90),
sem_policy: HalfLife::from_days(730),
sem_external_authority: HalfLife::from_days(180),
sem_agent_instruction: HalfLife::from_days(730),
sem_librarian_assignment: HalfLife::no_decay(),
sem_pending_verification: HalfLife::from_days(30),
epi_observation: HalfLife::from_days(90),
epi_self_report: HalfLife::from_days(30),
epi_participant_report: HalfLife::from_days(60),
pro_any: HalfLife::no_decay(),
}
}
#[must_use]
#[allow(clippy::match_same_arms)]
pub const fn half_life_for(
&self,
memory_kind: MemoryKindTag,
source_kind: SourceKind,
) -> Option<HalfLife> {
match (memory_kind, source_kind) {
(MemoryKindTag::Semantic, SourceKind::Profile) => Some(self.sem_profile),
(MemoryKindTag::Semantic, SourceKind::Observation) => Some(self.sem_observation),
(MemoryKindTag::Semantic, SourceKind::SelfReport) => Some(self.sem_self_report),
(MemoryKindTag::Semantic, SourceKind::ParticipantReport) => {
Some(self.sem_participant_report)
}
(MemoryKindTag::Semantic, SourceKind::Document) => Some(self.sem_document),
(MemoryKindTag::Semantic, SourceKind::Registry) => Some(self.sem_registry),
(MemoryKindTag::Semantic, SourceKind::Policy) => Some(self.sem_policy),
(MemoryKindTag::Semantic, SourceKind::ExternalAuthority) => {
Some(self.sem_external_authority)
}
(MemoryKindTag::Semantic, SourceKind::AgentInstruction) => {
Some(self.sem_agent_instruction)
}
(MemoryKindTag::Semantic, SourceKind::LibrarianAssignment) => {
Some(self.sem_librarian_assignment)
}
(MemoryKindTag::Semantic, SourceKind::PendingVerification) => {
Some(self.sem_pending_verification)
}
(MemoryKindTag::Episodic, SourceKind::Observation) => Some(self.epi_observation),
(MemoryKindTag::Episodic, SourceKind::SelfReport) => Some(self.epi_self_report),
(MemoryKindTag::Episodic, SourceKind::ParticipantReport) => {
Some(self.epi_participant_report)
}
(MemoryKindTag::Procedural, _) => Some(self.pro_any),
(MemoryKindTag::Inferential, _) => None,
(MemoryKindTag::Episodic, _) => None,
}
}
}
impl Default for DecayConfig {
fn default() -> Self {
Self::librarian_defaults()
}
}
#[derive(Debug, Error)]
pub enum DecayConfigError {
#[error("toml parse error: {0}")]
Parse(#[from] toml::de::Error),
#[error("{path}: expected table")]
ExpectedTable {
path: &'static str,
},
#[error("{path}: expected non-negative integer (days)")]
ExpectedNonNegInteger {
path: &'static str,
},
#[error("{path}: value {value} is not a valid half-life (days ≥ 0)")]
InvalidDays {
path: &'static str,
value: i64,
},
}
impl DecayConfig {
pub fn from_toml(toml_str: &str) -> Result<Self, DecayConfigError> {
let mut cfg = Self::librarian_defaults();
cfg.apply_toml(toml_str)?;
Ok(cfg)
}
pub fn apply_toml(&mut self, toml_str: &str) -> Result<(), DecayConfigError> {
let root: toml::Table = toml_str.parse()?;
let Some(decay) = root.get("decay") else {
return Ok(());
};
let toml::Value::Table(decay) = decay else {
return Err(DecayConfigError::ExpectedTable { path: "decay" });
};
if let Some(section) = decay.get("semantic") {
let toml::Value::Table(sem) = section else {
return Err(DecayConfigError::ExpectedTable {
path: "decay.semantic",
});
};
apply_section(
sem,
"decay.semantic",
&mut [
("profile", &mut self.sem_profile),
("observation", &mut self.sem_observation),
("self_report", &mut self.sem_self_report),
("participant_report", &mut self.sem_participant_report),
("document", &mut self.sem_document),
("registry", &mut self.sem_registry),
("policy", &mut self.sem_policy),
("external_authority", &mut self.sem_external_authority),
("agent_instruction", &mut self.sem_agent_instruction),
("librarian_assignment", &mut self.sem_librarian_assignment),
("pending_verification", &mut self.sem_pending_verification),
],
)?;
}
if let Some(section) = decay.get("episodic") {
let toml::Value::Table(epi) = section else {
return Err(DecayConfigError::ExpectedTable {
path: "decay.episodic",
});
};
apply_section(
epi,
"decay.episodic",
&mut [
("observation", &mut self.epi_observation),
("self_report", &mut self.epi_self_report),
("participant_report", &mut self.epi_participant_report),
],
)?;
}
if let Some(section) = decay.get("procedural") {
let toml::Value::Table(pro) = section else {
return Err(DecayConfigError::ExpectedTable {
path: "decay.procedural",
});
};
apply_section(pro, "decay.procedural", &mut [("any", &mut self.pro_any)])?;
}
Ok(())
}
}
fn apply_section(
section: &toml::Table,
section_path: &'static str,
slots: &mut [(&'static str, &mut HalfLife)],
) -> Result<(), DecayConfigError> {
for (key, slot) in slots {
let Some(value) = section.get(*key) else {
continue;
};
let toml::Value::Integer(days) = value else {
return Err(DecayConfigError::ExpectedNonNegInteger { path: section_path });
};
if *days < 0 {
return Err(DecayConfigError::InvalidDays {
path: section_path,
value: *days,
});
}
#[allow(clippy::cast_sign_loss)]
let days_u64 = *days as u64;
**slot = HalfLife::from_days(days_u64);
}
Ok(())
}
#[must_use]
pub fn effective_confidence(
stored: Confidence,
elapsed_ms: u64,
memory_kind: MemoryKindTag,
source_kind: SourceKind,
flags: DecayFlags,
config: &DecayConfig,
) -> Confidence {
if flags.suspends_decay() {
return stored;
}
let Some(half_life) = config.half_life_for(memory_kind, source_kind) else {
return stored;
};
let factor = decay_factor_u16(elapsed_ms, half_life);
let product = u32::from(stored.as_u16()) * u32::from(factor);
let scaled = (product + u32::from(u16::MAX) / 2) / u32::from(u16::MAX);
#[allow(clippy::cast_possible_truncation)]
Confidence::from_u16(scaled as u16)
}
#[cfg(test)]
mod tests {
use super::*;
fn c(f: f32) -> Confidence {
Confidence::try_from_f32(f).expect("in range")
}
#[test]
fn table_first_entry_is_unit_factor() {
assert_eq!(DECAY_TABLE[0], u16::MAX);
}
#[test]
fn table_is_strictly_monotonically_decreasing() {
for i in 1..256 {
assert!(
DECAY_TABLE[i] < DECAY_TABLE[i - 1],
"non-monotonic at index {i}: {} >= {}",
DECAY_TABLE[i],
DECAY_TABLE[i - 1]
);
}
}
#[test]
fn no_decay_half_life_returns_unit() {
assert_eq!(decay_factor_u16(1_000_000, HalfLife::no_decay()), u16::MAX);
assert_eq!(decay_factor_u16(u64::MAX, HalfLife::no_decay()), u16::MAX);
}
#[test]
fn zero_elapsed_returns_unit() {
assert_eq!(decay_factor_u16(0, HalfLife::from_days(180)), u16::MAX);
}
#[test]
fn one_half_life_returns_approximately_half() {
let factor = decay_factor_u16(180 * DAY_MS, HalfLife::from_days(180));
assert!(factor.abs_diff(u16::MAX / 2) <= 1);
}
#[test]
fn sixteen_half_lives_saturate_to_zero() {
let factor = decay_factor_u16(16 * 180 * DAY_MS, HalfLife::from_days(180));
assert_eq!(factor, 0);
}
#[test]
fn elapsed_near_u64_max_saturates_to_zero_not_panics() {
let factor = decay_factor_u16(u64::MAX, HalfLife::from_millis(1));
assert_eq!(factor, 0);
let factor = decay_factor_u16(u64::MAX - 1, HalfLife::from_days(180));
assert_eq!(factor, 0);
}
#[test]
fn half_life_of_one_millisecond_is_the_tightest_divisor() {
let one_ms = HalfLife::from_millis(1);
assert_eq!(decay_factor_u16(0, one_ms), u16::MAX);
assert_eq!(decay_factor_u16(1, one_ms), u16::MAX >> 1);
assert_eq!(decay_factor_u16(16, one_ms), 0);
}
#[test]
fn decay_is_monotonic_in_elapsed() {
let hl = HalfLife::from_days(180);
let mut prev = u16::MAX;
for days in (0_u64..=1800).step_by(7) {
let f = decay_factor_u16(days * DAY_MS, hl);
assert!(f <= prev, "non-monotonic at day {days}");
prev = f;
}
}
#[test]
fn pinned_short_circuits_to_stored() {
let cfg = DecayConfig::librarian_defaults();
let stored = c(0.8);
let eff = effective_confidence(
stored,
10 * 365 * DAY_MS,
MemoryKindTag::Semantic,
SourceKind::Observation,
DecayFlags {
pinned: true,
authoritative: false,
},
&cfg,
);
assert_eq!(eff, stored);
}
#[test]
fn authoritative_short_circuits_to_stored() {
let cfg = DecayConfig::librarian_defaults();
let stored = c(0.8);
let eff = effective_confidence(
stored,
10 * 365 * DAY_MS,
MemoryKindTag::Semantic,
SourceKind::Observation,
DecayFlags {
pinned: false,
authoritative: true,
},
&cfg,
);
assert_eq!(eff, stored);
}
#[test]
fn librarian_assignment_never_decays() {
let cfg = DecayConfig::librarian_defaults();
let stored = c(1.0);
let eff = effective_confidence(
stored,
100 * 365 * DAY_MS,
MemoryKindTag::Semantic,
SourceKind::LibrarianAssignment,
DecayFlags::default(),
&cfg,
);
assert_eq!(eff, stored);
}
#[test]
fn procedural_time_decay_is_disabled() {
let cfg = DecayConfig::librarian_defaults();
let stored = c(0.9);
let eff = effective_confidence(
stored,
10 * 365 * DAY_MS,
MemoryKindTag::Procedural,
SourceKind::AgentInstruction,
DecayFlags::default(),
&cfg,
);
assert_eq!(eff, stored);
}
#[test]
fn inferential_is_passthrough_at_this_layer() {
let cfg = DecayConfig::librarian_defaults();
let stored = c(0.7);
let eff = effective_confidence(
stored,
10 * 365 * DAY_MS,
MemoryKindTag::Inferential,
SourceKind::Observation,
DecayFlags::default(),
&cfg,
);
assert_eq!(eff, stored);
}
#[test]
fn one_half_life_halves_stored_confidence() {
let cfg = DecayConfig::librarian_defaults();
let stored = c(0.8);
let eff = effective_confidence(
stored,
180 * DAY_MS,
MemoryKindTag::Semantic,
SourceKind::Observation,
DecayFlags::default(),
&cfg,
);
let target = i32::from(stored.as_u16()) / 2;
let actual = i32::from(eff.as_u16());
assert!(
(actual - target).abs() <= 1,
"expected ≈{target}, got {actual}"
);
}
#[test]
fn defaults_match_spec_table() {
let cfg = DecayConfig::librarian_defaults();
assert_eq!(cfg.sem_profile, HalfLife::from_days(730));
assert_eq!(cfg.sem_pending_verification, HalfLife::from_days(30));
assert_eq!(cfg.sem_librarian_assignment, HalfLife::no_decay());
assert_eq!(cfg.epi_self_report, HalfLife::from_days(30));
assert_eq!(cfg.pro_any, HalfLife::no_decay());
}
#[test]
fn toml_empty_input_preserves_defaults() {
let cfg = DecayConfig::from_toml("").expect("parse");
assert_eq!(cfg, DecayConfig::librarian_defaults());
}
#[test]
fn toml_overrides_semantic_half_lives() {
let toml = r"
[decay.semantic]
profile = 30
observation = 365
";
let cfg = DecayConfig::from_toml(toml).expect("parse");
assert_eq!(cfg.sem_profile, HalfLife::from_days(30));
assert_eq!(cfg.sem_observation, HalfLife::from_days(365));
assert_eq!(cfg.sem_document, HalfLife::from_days(365)); }
#[test]
fn toml_zero_encodes_no_decay() {
let toml = r"
[decay.semantic]
librarian_assignment = 0
profile = 0
";
let cfg = DecayConfig::from_toml(toml).expect("parse");
assert_eq!(cfg.sem_librarian_assignment, HalfLife::no_decay());
assert_eq!(cfg.sem_profile, HalfLife::no_decay());
}
#[test]
fn toml_unknown_keys_are_ignored() {
let toml = r"
[decay.semantic]
profile = 30
future_source_kind = 42 # not in the v1 registry — must be ignored
[decay.not_a_real_section]
key = 1
";
let cfg = DecayConfig::from_toml(toml).expect("parse");
assert_eq!(cfg.sem_profile, HalfLife::from_days(30));
}
#[test]
fn toml_rejects_negative_days() {
let toml = r"
[decay.semantic]
profile = -1
";
let err = DecayConfig::from_toml(toml).expect_err("negative");
assert!(matches!(err, DecayConfigError::InvalidDays { .. }));
}
#[test]
fn toml_rejects_non_integer_values() {
let toml = r#"
[decay.semantic]
profile = "thirty"
"#;
let err = DecayConfig::from_toml(toml).expect_err("string");
assert!(matches!(
err,
DecayConfigError::ExpectedNonNegInteger { .. }
));
}
#[test]
fn toml_rejects_wrong_section_type() {
let toml = r"
decay = 42
";
let err = DecayConfig::from_toml(toml).expect_err("not a table");
assert!(matches!(
err,
DecayConfigError::ExpectedTable { path: "decay" }
));
}
#[test]
fn toml_overrides_episodic_and_procedural() {
let toml = r"
[decay.episodic]
observation = 7
self_report = 3
participant_report = 14
[decay.procedural]
any = 365
";
let cfg = DecayConfig::from_toml(toml).expect("parse");
assert_eq!(cfg.epi_observation, HalfLife::from_days(7));
assert_eq!(cfg.epi_self_report, HalfLife::from_days(3));
assert_eq!(cfg.epi_participant_report, HalfLife::from_days(14));
assert_eq!(cfg.pro_any, HalfLife::from_days(365));
}
#[test]
fn apply_toml_is_additive() {
let mut cfg = DecayConfig::librarian_defaults();
cfg.apply_toml("[decay.semantic]\nprofile = 30")
.expect("first");
assert_eq!(cfg.sem_profile, HalfLife::from_days(30));
cfg.apply_toml("[decay.semantic]\nobservation = 7")
.expect("second");
assert_eq!(cfg.sem_profile, HalfLife::from_days(30));
assert_eq!(cfg.sem_observation, HalfLife::from_days(7));
}
#[test]
fn toml_reload_changes_effective_confidence_without_restart() {
let mut cfg = DecayConfig::librarian_defaults();
let stored = c(1.0);
let elapsed = 30 * DAY_MS;
let before = effective_confidence(
stored,
elapsed,
MemoryKindTag::Semantic,
SourceKind::Observation,
DecayFlags::default(),
&cfg,
);
cfg.apply_toml("[decay.semantic]\nobservation = 1")
.expect("reload");
let after = effective_confidence(
stored,
elapsed,
MemoryKindTag::Semantic,
SourceKind::Observation,
DecayFlags::default(),
&cfg,
);
assert!(
after < before,
"reload did not accelerate decay: before={before:?} after={after:?}"
);
}
#[test]
fn user_override_takes_effect_at_runtime() {
let mut cfg = DecayConfig::librarian_defaults();
let stored = c(1.0);
let baseline = effective_confidence(
stored,
180 * DAY_MS,
MemoryKindTag::Semantic,
SourceKind::Observation,
DecayFlags::default(),
&cfg,
);
cfg.sem_observation = HalfLife::from_days(90);
let overridden = effective_confidence(
stored,
180 * DAY_MS,
MemoryKindTag::Semantic,
SourceKind::Observation,
DecayFlags::default(),
&cfg,
);
assert!(
overridden < baseline,
"override should accelerate decay; baseline={baseline:?} overridden={overridden:?}"
);
}
}