Skip to main content

mimir_core/
decay.rs

1//! Deterministic confidence-decay model per
2//! `docs/concepts/confidence-decay.md`.
3//!
4//! Exponential decay: `effective = stored × 2^(-elapsed/half_life)`,
5//! computed in integer fixed-point at the `u16` confidence resolution
6//! (spec § 5.1 formula, § 13 invariant 2 bit-identity across
7//! architectures). A 256-entry lookup table `DECAY_TABLE` covers the
8//! fractional exponent with 8 bits of precision; the integer-exponent
9//! part is a right-shift. The lookup table values are baked into
10//! source (offline-computed `round(2^(-i/256) × 65535)`) so every
11//! build produces identical bytes.
12//!
13//! Surface area:
14//!
15//! - Core [`effective_confidence`] for non-Procedural, non-Inferential
16//!   memories — time decay against a per-`(memory_kind, source_kind)`
17//!   half-life.
18//! - Pinned / authoritative short-circuit (spec § 13 invariant 3).
19//! - v1 default parameter table (spec § 5.2) exposed as
20//!   [`DecayConfig::librarian_defaults`]. All fields are `pub` for
21//!   runtime user override (spec § 13 invariant 5).
22//! - `mimir.toml`-shaped overrides via
23//!   [`DecayConfig::from_toml`] / [`DecayConfig::apply_toml`] (spec
24//!   § 1 graduation criterion #4) — days-valued integers; `0` encodes
25//!   the § 5.3 infinity sentinel; unlisted keys fall back to
26//!   librarian defaults; unknown keys are silently ignored for
27//!   forward-compatibility.
28//!
29//! Deferred: Procedural activity weighting (§ 6), Inferential parent-
30//! tracking (§ 9 — composes with `inference_methods::InferenceMethod`
31//! by the caller).
32
33use thiserror::Error;
34
35use crate::confidence::Confidence;
36use crate::memory_kind::MemoryKindTag;
37use crate::source_kind::SourceKind;
38
39// -------------------------------------------------------------------
40// Lookup table
41// -------------------------------------------------------------------
42
43/// `DECAY_TABLE[i] = round(2^(-i/256) × 65535)` for `i` in `[0, 256)`.
44/// Baked into source for bit-identical bytes across builds and
45/// architectures — any regeneration must produce identical values or
46/// the spec § 13 invariant 2 is violated.
47#[rustfmt::skip]
48const DECAY_TABLE: [u16; 256] = [
49    65535, 65358, 65181, 65005, 64829, 64654, 64479, 64305,
50    64131, 63957, 63784, 63612, 63440, 63268, 63097, 62927,
51    62757, 62587, 62418, 62249, 62081, 61913, 61745, 61578,
52    61412, 61246, 61080, 60915, 60750, 60586, 60422, 60259,
53    60096, 59933, 59771, 59610, 59449, 59288, 59127, 58968,
54    58808, 58649, 58491, 58332, 58175, 58017, 57860, 57704,
55    57548, 57392, 57237, 57082, 56928, 56774, 56621, 56468,
56    56315, 56163, 56011, 55859, 55708, 55558, 55407, 55258,
57    55108, 54959, 54811, 54662, 54515, 54367, 54220, 54074,
58    53927, 53781, 53636, 53491, 53346, 53202, 53058, 52915,
59    52772, 52629, 52487, 52345, 52203, 52062, 51921, 51781,
60    51641, 51501, 51362, 51223, 51085, 50947, 50809, 50671,
61    50534, 50398, 50261, 50126, 49990, 49855, 49720, 49586,
62    49452, 49318, 49184, 49051, 48919, 48787, 48655, 48523,
63    48392, 48261, 48131, 48000, 47871, 47741, 47612, 47483,
64    47355, 47227, 47099, 46972, 46845, 46718, 46592, 46466,
65    46340, 46215, 46090, 45965, 45841, 45717, 45593, 45470,
66    45347, 45225, 45102, 44980, 44859, 44737, 44617, 44496,
67    44376, 44256, 44136, 44017, 43898, 43779, 43660, 43542,
68    43425, 43307, 43190, 43073, 42957, 42841, 42725, 42609,
69    42494, 42379, 42265, 42150, 42036, 41923, 41809, 41696,
70    41584, 41471, 41359, 41247, 41136, 41024, 40914, 40803,
71    40693, 40583, 40473, 40363, 40254, 40145, 40037, 39929,
72    39821, 39713, 39606, 39498, 39392, 39285, 39179, 39073,
73    38967, 38862, 38757, 38652, 38548, 38443, 38339, 38236,
74    38132, 38029, 37926, 37824, 37722, 37620, 37518, 37416,
75    37315, 37214, 37114, 37013, 36913, 36813, 36714, 36615,
76    36516, 36417, 36318, 36220, 36122, 36025, 35927, 35830,
77    35733, 35637, 35540, 35444, 35348, 35253, 35157, 35062,
78    34968, 34873, 34779, 34685, 34591, 34497, 34404, 34311,
79    34218, 34126, 34033, 33941, 33850, 33758, 33667, 33576,
80    33485, 33394, 33304, 33214, 33124, 33035, 32945, 32856,
81];
82
83// -------------------------------------------------------------------
84// Constants
85// -------------------------------------------------------------------
86
87/// One day in milliseconds — used for documentation and for converting
88/// the spec's day-valued half-lives into `u64` millis.
89pub const DAY_MS: u64 = 86_400_000;
90
91/// Sentinel for "no time decay" — the spec § 5.3 infinity encoding.
92/// [`effective_confidence`] treats this as `decay_factor = 1`.
93pub const NO_DECAY: u64 = 0;
94
95/// A per-class half-life, stored as milliseconds but constructed
96/// explicitly in days or millis.
97///
98/// The agent-facing `mimir.toml` loader and the librarian default
99/// table both deal in days; internal decay math is in milliseconds.
100/// The newtype keeps the unit explicit at the boundary so a future
101/// caller can't accidentally drop a `ClockTime` into a half-life
102/// slot (or vice versa).
103///
104/// The infinity case (`HalfLife::no_decay()` == `HalfLife::ZERO`)
105/// maps to the spec § 5.3 encoding — `effective_confidence` returns
106/// stored for any memory whose class has `HalfLife::ZERO`.
107///
108/// # Examples
109///
110/// ```
111/// use mimir_core::decay::{HalfLife, DAY_MS};
112/// assert_eq!(HalfLife::from_days(180).as_millis(), 180 * DAY_MS);
113/// assert_eq!(HalfLife::from_millis(500).as_millis(), 500);
114/// assert!(HalfLife::no_decay().is_no_decay());
115/// assert!(!HalfLife::from_days(1).is_no_decay());
116/// ```
117#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
118pub struct HalfLife(u64);
119
120impl HalfLife {
121    /// The spec § 5.3 infinity case — `decay_factor = 1` always.
122    pub const ZERO: Self = Self(NO_DECAY);
123
124    /// Construct from a day count.
125    #[must_use]
126    pub const fn from_days(days: u64) -> Self {
127        Self(days.saturating_mul(DAY_MS))
128    }
129
130    /// Construct from a raw millisecond count. `0` encodes
131    /// [`Self::no_decay`].
132    #[must_use]
133    pub const fn from_millis(millis: u64) -> Self {
134        Self(millis)
135    }
136
137    /// The spec § 5.3 "no time decay" sentinel.
138    #[must_use]
139    pub const fn no_decay() -> Self {
140        Self(NO_DECAY)
141    }
142
143    /// Raw millisecond representation — for the internal decay
144    /// math. Unit-explicit callers should prefer
145    /// `HalfLife`-typed values everywhere else.
146    #[must_use]
147    pub const fn as_millis(self) -> u64 {
148        self.0
149    }
150
151    /// `true` when this half-life encodes the spec § 5.3 infinity
152    /// case.
153    #[must_use]
154    pub const fn is_no_decay(self) -> bool {
155        self.0 == NO_DECAY
156    }
157}
158
159// Max exponent bits before the fractional result underflows u16.
160// `decay = frac >> n`; for frac ≤ 65535, `n ≥ 16` produces 0.
161const MAX_EXPONENT: u32 = 16;
162
163// Cap `elapsed_ms` so `elapsed_ms * 256` cannot overflow u64.
164const ELAPSED_CAP: u64 = u64::MAX / 256;
165
166// -------------------------------------------------------------------
167// Core decay primitive
168// -------------------------------------------------------------------
169
170/// Deterministic integer decay factor in `u16` fixed-point scale.
171///
172/// Returns `u16::MAX` (representing 1.0) when `half_life` is
173/// [`HalfLife::no_decay`], `0` when `elapsed_ms` saturates the
174/// exponent beyond representable precision, and a
175/// monotonically-decreasing value in between.
176///
177/// # Example
178///
179/// ```
180/// use mimir_core::decay::{decay_factor_u16, HalfLife, DAY_MS};
181/// // Zero elapsed → full factor (u16::MAX).
182/// assert_eq!(decay_factor_u16(0, HalfLife::from_days(180)), u16::MAX);
183/// // One half-life elapsed → ≈ 0.5 (u16::MAX / 2, ±1 ULP).
184/// let half = decay_factor_u16(180 * DAY_MS, HalfLife::from_days(180));
185/// assert!(half.abs_diff(u16::MAX / 2) <= 1);
186/// // Infinite half-life → no decay.
187/// assert_eq!(
188///     decay_factor_u16(1_000 * DAY_MS, HalfLife::no_decay()),
189///     u16::MAX,
190/// );
191/// ```
192#[must_use]
193pub fn decay_factor_u16(elapsed_ms: u64, half_life: HalfLife) -> u16 {
194    let half_life_ms = half_life.as_millis();
195    if half_life_ms == NO_DECAY {
196        return u16::MAX;
197    }
198    let elapsed = elapsed_ms.min(ELAPSED_CAP);
199    let k_q8 = (elapsed.saturating_mul(256)) / half_life_ms;
200    #[allow(clippy::cast_possible_truncation)]
201    let n = (k_q8 >> 8) as u32;
202    if n >= MAX_EXPONENT {
203        return 0;
204    }
205    let i = (k_q8 & 0xFF) as usize;
206    let frac = u32::from(DECAY_TABLE[i]);
207    #[allow(clippy::cast_possible_truncation)]
208    let result = (frac >> n) as u16;
209    result
210}
211
212// -------------------------------------------------------------------
213// Decay flags (pinning / authoritative)
214// -------------------------------------------------------------------
215
216/// Runtime flags that suspend decay per spec §§ 7–8.
217#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
218pub struct DecayFlags {
219    /// Agent-invokable pin — `effective = stored`.
220    pub pinned: bool,
221    /// Operator-declared authoritative — `effective = stored` and
222    /// distinct surfacing semantics at read time (see
223    /// `read-protocol.md` amendment).
224    pub authoritative: bool,
225}
226
227impl DecayFlags {
228    /// `true` if either flag suspends decay.
229    #[must_use]
230    pub const fn suspends_decay(self) -> bool {
231        self.pinned || self.authoritative
232    }
233}
234
235// -------------------------------------------------------------------
236// DecayConfig
237// -------------------------------------------------------------------
238
239/// Per-`(memory-kind, source-kind)` decay parameter table with the
240/// v1 librarian defaults from spec § 5.2. Fields are `pub` so callers
241/// can override at runtime without restart (spec § 13 invariant 5);
242/// [`apply_toml`](Self::apply_toml) reads `mimir.toml`-shaped
243/// overrides in days.
244///
245/// Half-lives are stored in **milliseconds**; [`NO_DECAY`] encodes the
246/// spec § 5.3 infinity case (`librarian_assignment`, Procedural
247/// time-decay).
248#[derive(Clone, Debug, PartialEq, Eq)]
249pub struct DecayConfig {
250    /// Semantic × `@profile`.
251    pub sem_profile: HalfLife,
252    /// Semantic × `@observation`.
253    pub sem_observation: HalfLife,
254    /// Semantic × `@self_report`.
255    pub sem_self_report: HalfLife,
256    /// Semantic × `@participant_report` (no explicit default — mirrors
257    /// `@self_report` for unlisted pairs).
258    pub sem_participant_report: HalfLife,
259    /// Semantic × `@document`.
260    pub sem_document: HalfLife,
261    /// Semantic × `@registry`.
262    pub sem_registry: HalfLife,
263    /// Semantic × `@policy` (no explicit default; mirrors
264    /// `@agent_instruction`).
265    pub sem_policy: HalfLife,
266    /// Semantic × `@external_authority`.
267    pub sem_external_authority: HalfLife,
268    /// Semantic × `@agent_instruction`.
269    pub sem_agent_instruction: HalfLife,
270    /// Semantic × `@librarian_assignment` — no decay.
271    pub sem_librarian_assignment: HalfLife,
272    /// Semantic × `@pending_verification`.
273    pub sem_pending_verification: HalfLife,
274    /// Episodic × `@observation`.
275    pub epi_observation: HalfLife,
276    /// Episodic × `@self_report`.
277    pub epi_self_report: HalfLife,
278    /// Episodic × `@participant_report`.
279    pub epi_participant_report: HalfLife,
280    /// Procedural — any source. [`HalfLife::no_decay`] (spec § 6
281    /// activity-weighted instead — not implemented in 5.8).
282    pub pro_any: HalfLife,
283}
284
285impl DecayConfig {
286    /// v1 default parameters per spec § 5.2. User overrides happen by
287    /// mutating the struct in-place.
288    #[must_use]
289    pub const fn librarian_defaults() -> Self {
290        Self {
291            sem_profile: HalfLife::from_days(730),
292            sem_observation: HalfLife::from_days(180),
293            sem_self_report: HalfLife::from_days(90),
294            sem_participant_report: HalfLife::from_days(90),
295            sem_document: HalfLife::from_days(365),
296            sem_registry: HalfLife::from_days(90),
297            sem_policy: HalfLife::from_days(730),
298            sem_external_authority: HalfLife::from_days(180),
299            sem_agent_instruction: HalfLife::from_days(730),
300            sem_librarian_assignment: HalfLife::no_decay(),
301            sem_pending_verification: HalfLife::from_days(30),
302            epi_observation: HalfLife::from_days(90),
303            epi_self_report: HalfLife::from_days(30),
304            epi_participant_report: HalfLife::from_days(60),
305            pro_any: HalfLife::no_decay(),
306        }
307    }
308
309    /// Look up the half-life for a given memory kind / source kind
310    /// pair. Returns `None` for pairs that `SourceKind::admits`
311    /// rejects (the caller should validate upstream) or for
312    /// Inferential memories, which decay via their parents rather than
313    /// a per-pair half-life.
314    #[must_use]
315    #[allow(clippy::match_same_arms)]
316    pub const fn half_life_for(
317        &self,
318        memory_kind: MemoryKindTag,
319        source_kind: SourceKind,
320    ) -> Option<HalfLife> {
321        match (memory_kind, source_kind) {
322            (MemoryKindTag::Semantic, SourceKind::Profile) => Some(self.sem_profile),
323            (MemoryKindTag::Semantic, SourceKind::Observation) => Some(self.sem_observation),
324            (MemoryKindTag::Semantic, SourceKind::SelfReport) => Some(self.sem_self_report),
325            (MemoryKindTag::Semantic, SourceKind::ParticipantReport) => {
326                Some(self.sem_participant_report)
327            }
328            (MemoryKindTag::Semantic, SourceKind::Document) => Some(self.sem_document),
329            (MemoryKindTag::Semantic, SourceKind::Registry) => Some(self.sem_registry),
330            (MemoryKindTag::Semantic, SourceKind::Policy) => Some(self.sem_policy),
331            (MemoryKindTag::Semantic, SourceKind::ExternalAuthority) => {
332                Some(self.sem_external_authority)
333            }
334            (MemoryKindTag::Semantic, SourceKind::AgentInstruction) => {
335                Some(self.sem_agent_instruction)
336            }
337            (MemoryKindTag::Semantic, SourceKind::LibrarianAssignment) => {
338                Some(self.sem_librarian_assignment)
339            }
340            (MemoryKindTag::Semantic, SourceKind::PendingVerification) => {
341                Some(self.sem_pending_verification)
342            }
343            (MemoryKindTag::Episodic, SourceKind::Observation) => Some(self.epi_observation),
344            (MemoryKindTag::Episodic, SourceKind::SelfReport) => Some(self.epi_self_report),
345            (MemoryKindTag::Episodic, SourceKind::ParticipantReport) => {
346                Some(self.epi_participant_report)
347            }
348            (MemoryKindTag::Procedural, _) => Some(self.pro_any),
349            // Inferential decays via parents (spec § 9) — caller must
350            // recompute from current parent effective confidences.
351            (MemoryKindTag::Inferential, _) => None,
352            // Episodic paired with a non-Episodic-admitted source —
353            // validation should have rejected upstream; we fall through
354            // to None so the caller's bug path is explicit.
355            (MemoryKindTag::Episodic, _) => None,
356        }
357    }
358}
359
360impl Default for DecayConfig {
361    fn default() -> Self {
362        Self::librarian_defaults()
363    }
364}
365
366// -------------------------------------------------------------------
367// TOML loading (spec § 5.2, § 13 invariant 5, graduation criterion #4)
368// -------------------------------------------------------------------
369
370/// Errors produced by [`DecayConfig::apply_toml`] and
371/// [`DecayConfig::from_toml`].
372#[derive(Debug, Error)]
373pub enum DecayConfigError {
374    /// The TOML input failed to parse. Wraps `toml::de::Error`
375    /// directly so callers can route on its structured
376    /// `span()` / `message()` without re-parsing a string.
377    #[error("toml parse error: {0}")]
378    Parse(#[from] toml::de::Error),
379    /// A section was the wrong TOML value type (e.g. `decay` as an
380    /// integer instead of a table).
381    #[error("{path}: expected table")]
382    ExpectedTable {
383        /// Dotted path to the offending key.
384        path: &'static str,
385    },
386    /// A leaf value was the wrong type — half-lives must be
387    /// non-negative integers (days).
388    #[error("{path}: expected non-negative integer (days)")]
389    ExpectedNonNegInteger {
390        /// Dotted path to the offending key.
391        path: &'static str,
392    },
393    /// A recognized key carried an unknown value (e.g. a negative
394    /// integer, or a floating-point where integer was expected).
395    #[error("{path}: value {value} is not a valid half-life (days ≥ 0)")]
396    InvalidDays {
397        /// Dotted path to the offending key.
398        path: &'static str,
399        /// The offending value as it appeared in the TOML.
400        value: i64,
401    },
402}
403
404impl DecayConfig {
405    /// Parse `mimir.toml`-shaped overrides on top of the v1 librarian
406    /// defaults (spec § 5.2). Accepted TOML shape:
407    ///
408    /// ```toml
409    /// [decay.semantic]
410    /// profile = 730            # days; `0` encodes NO_DECAY (spec § 5.3)
411    /// observation = 180
412    /// # any of the other SourceKind keys under the `[decay.semantic]`
413    /// # table; unlisted keys fall back to librarian defaults
414    ///
415    /// [decay.episodic]
416    /// observation = 90
417    /// self_report = 30
418    /// participant_report = 60
419    ///
420    /// [decay.procedural]
421    /// any = 0                  # v1 uses activity weighting instead
422    /// ```
423    ///
424    /// Unknown TOML keys are silently ignored per the spec's
425    /// "unlisted keys fall back" convention; the v1 goal is
426    /// tolerance of future extensions.
427    ///
428    /// # Errors
429    ///
430    /// - [`DecayConfigError::Parse`] if the TOML doesn't parse.
431    /// - [`DecayConfigError::ExpectedTable`] if `decay` or a known
432    ///   subsection is not a TOML table.
433    /// - [`DecayConfigError::ExpectedNonNegInteger`] if a recognized
434    ///   leaf key is not an integer.
435    /// - [`DecayConfigError::InvalidDays`] if the integer is negative.
436    pub fn from_toml(toml_str: &str) -> Result<Self, DecayConfigError> {
437        let mut cfg = Self::librarian_defaults();
438        cfg.apply_toml(toml_str)?;
439        Ok(cfg)
440    }
441
442    /// Apply TOML-encoded overrides in place. See [`from_toml`](Self::from_toml)
443    /// for the accepted schema.
444    ///
445    /// # Errors
446    ///
447    /// See [`from_toml`](Self::from_toml).
448    pub fn apply_toml(&mut self, toml_str: &str) -> Result<(), DecayConfigError> {
449        let root: toml::Table = toml_str.parse()?;
450        let Some(decay) = root.get("decay") else {
451            return Ok(());
452        };
453        let toml::Value::Table(decay) = decay else {
454            return Err(DecayConfigError::ExpectedTable { path: "decay" });
455        };
456        if let Some(section) = decay.get("semantic") {
457            let toml::Value::Table(sem) = section else {
458                return Err(DecayConfigError::ExpectedTable {
459                    path: "decay.semantic",
460                });
461            };
462            apply_section(
463                sem,
464                "decay.semantic",
465                &mut [
466                    ("profile", &mut self.sem_profile),
467                    ("observation", &mut self.sem_observation),
468                    ("self_report", &mut self.sem_self_report),
469                    ("participant_report", &mut self.sem_participant_report),
470                    ("document", &mut self.sem_document),
471                    ("registry", &mut self.sem_registry),
472                    ("policy", &mut self.sem_policy),
473                    ("external_authority", &mut self.sem_external_authority),
474                    ("agent_instruction", &mut self.sem_agent_instruction),
475                    ("librarian_assignment", &mut self.sem_librarian_assignment),
476                    ("pending_verification", &mut self.sem_pending_verification),
477                ],
478            )?;
479        }
480        if let Some(section) = decay.get("episodic") {
481            let toml::Value::Table(epi) = section else {
482                return Err(DecayConfigError::ExpectedTable {
483                    path: "decay.episodic",
484                });
485            };
486            apply_section(
487                epi,
488                "decay.episodic",
489                &mut [
490                    ("observation", &mut self.epi_observation),
491                    ("self_report", &mut self.epi_self_report),
492                    ("participant_report", &mut self.epi_participant_report),
493                ],
494            )?;
495        }
496        if let Some(section) = decay.get("procedural") {
497            let toml::Value::Table(pro) = section else {
498                return Err(DecayConfigError::ExpectedTable {
499                    path: "decay.procedural",
500                });
501            };
502            apply_section(pro, "decay.procedural", &mut [("any", &mut self.pro_any)])?;
503        }
504        Ok(())
505    }
506}
507
508/// Apply a set of `(key, &mut HalfLife)` slots against a TOML
509/// subsection. Values are read as integers (days) and wrapped in a
510/// `HalfLife::from_days`; `0` encodes [`HalfLife::no_decay`].
511fn apply_section(
512    section: &toml::Table,
513    section_path: &'static str,
514    slots: &mut [(&'static str, &mut HalfLife)],
515) -> Result<(), DecayConfigError> {
516    for (key, slot) in slots {
517        let Some(value) = section.get(*key) else {
518            continue;
519        };
520        let toml::Value::Integer(days) = value else {
521            // Build a dotted path — but we can't easily allocate a
522            // &'static str per (section, key) pair without leaking or
523            // pulling in a string-ID map. Use the section path; the
524            // caller gets a close-enough pointer.
525            return Err(DecayConfigError::ExpectedNonNegInteger { path: section_path });
526        };
527        if *days < 0 {
528            return Err(DecayConfigError::InvalidDays {
529                path: section_path,
530                value: *days,
531            });
532        }
533        #[allow(clippy::cast_sign_loss)]
534        let days_u64 = *days as u64;
535        **slot = HalfLife::from_days(days_u64);
536    }
537    Ok(())
538}
539
540// -------------------------------------------------------------------
541// Effective-confidence computation
542// -------------------------------------------------------------------
543
544/// Compute the effective confidence for a non-Inferential memory at
545/// read time. Pinned or operator-authoritative memories short-circuit
546/// to `stored` (spec § 13 invariant 3).
547///
548/// `elapsed_ms` is `now - valid_at` for Semantic/Inferential or
549/// `now - committed_at` for Procedural (where `valid_at == committed_at`)
550/// — the caller supplies the already-differenced value so this function
551/// doesn't reach into `ClockTime`.
552///
553/// # Example
554///
555/// ```
556/// use mimir_core::decay::{effective_confidence, DecayConfig, DecayFlags, DAY_MS};
557/// use mimir_core::{Confidence, MemoryKindTag, SourceKind};
558/// let cfg = DecayConfig::librarian_defaults();
559/// let stored = Confidence::try_from_f32(0.8).unwrap();
560/// let one_hl = 180 * DAY_MS; // one Semantic@observation half-life
561/// let effective = effective_confidence(
562///     stored,
563///     one_hl,
564///     MemoryKindTag::Semantic,
565///     SourceKind::Observation,
566///     DecayFlags::default(),
567///     &cfg,
568/// );
569/// // After one half-life, effective is approximately half of stored.
570/// let target = stored.as_f32() * 0.5;
571/// assert!((effective.as_f32() - target).abs() < 0.01);
572/// ```
573#[must_use]
574pub fn effective_confidence(
575    stored: Confidence,
576    elapsed_ms: u64,
577    memory_kind: MemoryKindTag,
578    source_kind: SourceKind,
579    flags: DecayFlags,
580    config: &DecayConfig,
581) -> Confidence {
582    if flags.suspends_decay() {
583        return stored;
584    }
585    // Inferential: caller is responsible for supplying the effective
586    // confidence derived from its parents. If Inferential reaches this
587    // function by accident, decay is a no-op rather than crashing —
588    // the caller's test is expected to catch the misuse.
589    let Some(half_life) = config.half_life_for(memory_kind, source_kind) else {
590        return stored;
591    };
592    let factor = decay_factor_u16(elapsed_ms, half_life);
593    let product = u32::from(stored.as_u16()) * u32::from(factor);
594    // round-to-nearest division by u16::MAX.
595    let scaled = (product + u32::from(u16::MAX) / 2) / u32::from(u16::MAX);
596    #[allow(clippy::cast_possible_truncation)]
597    Confidence::from_u16(scaled as u16)
598}
599
600// -------------------------------------------------------------------
601// Tests
602// -------------------------------------------------------------------
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607
608    fn c(f: f32) -> Confidence {
609        Confidence::try_from_f32(f).expect("in range")
610    }
611
612    // ----- Table + decay_factor_u16 -----
613
614    #[test]
615    fn table_first_entry_is_unit_factor() {
616        assert_eq!(DECAY_TABLE[0], u16::MAX);
617    }
618
619    #[test]
620    fn table_is_strictly_monotonically_decreasing() {
621        for i in 1..256 {
622            assert!(
623                DECAY_TABLE[i] < DECAY_TABLE[i - 1],
624                "non-monotonic at index {i}: {} >= {}",
625                DECAY_TABLE[i],
626                DECAY_TABLE[i - 1]
627            );
628        }
629    }
630
631    #[test]
632    fn no_decay_half_life_returns_unit() {
633        assert_eq!(decay_factor_u16(1_000_000, HalfLife::no_decay()), u16::MAX);
634        assert_eq!(decay_factor_u16(u64::MAX, HalfLife::no_decay()), u16::MAX);
635    }
636
637    #[test]
638    fn zero_elapsed_returns_unit() {
639        assert_eq!(decay_factor_u16(0, HalfLife::from_days(180)), u16::MAX);
640    }
641
642    #[test]
643    fn one_half_life_returns_approximately_half() {
644        let factor = decay_factor_u16(180 * DAY_MS, HalfLife::from_days(180));
645        assert!(factor.abs_diff(u16::MAX / 2) <= 1);
646    }
647
648    #[test]
649    fn sixteen_half_lives_saturate_to_zero() {
650        // 2^(-16) × u16::MAX ≈ 1.0, but our table's integer
651        // right-shift produces 0 at n == 16.
652        let factor = decay_factor_u16(16 * 180 * DAY_MS, HalfLife::from_days(180));
653        assert_eq!(factor, 0);
654    }
655
656    #[test]
657    fn elapsed_near_u64_max_saturates_to_zero_not_panics() {
658        // Exercises the `ELAPSED_CAP` saturating_mul guard — without
659        // the cap, `elapsed.saturating_mul(256)` in `decay_factor_u16`
660        // could overflow the u64 intermediate. Result must be 0
661        // (fully decayed), not a panic or wrapped value.
662        let factor = decay_factor_u16(u64::MAX, HalfLife::from_millis(1));
663        assert_eq!(factor, 0);
664        // Sentinel-adjacent values land in the same regime.
665        let factor = decay_factor_u16(u64::MAX - 1, HalfLife::from_days(180));
666        assert_eq!(factor, 0);
667    }
668
669    #[test]
670    fn half_life_of_one_millisecond_is_the_tightest_divisor() {
671        // Smallest legal half-life — no overflow, no divide-by-zero,
672        // result saturates to 0 once elapsed crosses 16 half-lives.
673        let one_ms = HalfLife::from_millis(1);
674        assert_eq!(decay_factor_u16(0, one_ms), u16::MAX);
675        // One half-life elapsed at this boundary: DECAY_TABLE[0] >> 1.
676        assert_eq!(decay_factor_u16(1, one_ms), u16::MAX >> 1);
677        // 16 ms is 16 half-lives; hits the MAX_EXPONENT saturation.
678        assert_eq!(decay_factor_u16(16, one_ms), 0);
679    }
680
681    #[test]
682    fn decay_is_monotonic_in_elapsed() {
683        // For a fixed half-life, longer elapsed → smaller or equal
684        // factor. Sampled check (property test elsewhere).
685        let hl = HalfLife::from_days(180);
686        let mut prev = u16::MAX;
687        for days in (0_u64..=1800).step_by(7) {
688            let f = decay_factor_u16(days * DAY_MS, hl);
689            assert!(f <= prev, "non-monotonic at day {days}");
690            prev = f;
691        }
692    }
693
694    // ----- effective_confidence -----
695
696    #[test]
697    fn pinned_short_circuits_to_stored() {
698        let cfg = DecayConfig::librarian_defaults();
699        let stored = c(0.8);
700        let eff = effective_confidence(
701            stored,
702            10 * 365 * DAY_MS,
703            MemoryKindTag::Semantic,
704            SourceKind::Observation,
705            DecayFlags {
706                pinned: true,
707                authoritative: false,
708            },
709            &cfg,
710        );
711        assert_eq!(eff, stored);
712    }
713
714    #[test]
715    fn authoritative_short_circuits_to_stored() {
716        let cfg = DecayConfig::librarian_defaults();
717        let stored = c(0.8);
718        let eff = effective_confidence(
719            stored,
720            10 * 365 * DAY_MS,
721            MemoryKindTag::Semantic,
722            SourceKind::Observation,
723            DecayFlags {
724                pinned: false,
725                authoritative: true,
726            },
727            &cfg,
728        );
729        assert_eq!(eff, stored);
730    }
731
732    #[test]
733    fn librarian_assignment_never_decays() {
734        let cfg = DecayConfig::librarian_defaults();
735        let stored = c(1.0);
736        let eff = effective_confidence(
737            stored,
738            100 * 365 * DAY_MS,
739            MemoryKindTag::Semantic,
740            SourceKind::LibrarianAssignment,
741            DecayFlags::default(),
742            &cfg,
743        );
744        assert_eq!(eff, stored);
745    }
746
747    #[test]
748    fn procedural_time_decay_is_disabled() {
749        // Procedural uses activity weighting (§ 6) not time decay;
750        // v1 stores `NO_DECAY` for the half-life.
751        let cfg = DecayConfig::librarian_defaults();
752        let stored = c(0.9);
753        let eff = effective_confidence(
754            stored,
755            10 * 365 * DAY_MS,
756            MemoryKindTag::Procedural,
757            SourceKind::AgentInstruction,
758            DecayFlags::default(),
759            &cfg,
760        );
761        assert_eq!(eff, stored);
762    }
763
764    #[test]
765    fn inferential_is_passthrough_at_this_layer() {
766        // Inferential doesn't time-decay here — it's recomputed from
767        // parent effective confidences by the caller. If it reaches
768        // this function, we return stored unchanged rather than
769        // crashing (defensive).
770        let cfg = DecayConfig::librarian_defaults();
771        let stored = c(0.7);
772        let eff = effective_confidence(
773            stored,
774            10 * 365 * DAY_MS,
775            MemoryKindTag::Inferential,
776            SourceKind::Observation,
777            DecayFlags::default(),
778            &cfg,
779        );
780        assert_eq!(eff, stored);
781    }
782
783    #[test]
784    fn one_half_life_halves_stored_confidence() {
785        let cfg = DecayConfig::librarian_defaults();
786        let stored = c(0.8);
787        let eff = effective_confidence(
788            stored,
789            180 * DAY_MS,
790            MemoryKindTag::Semantic,
791            SourceKind::Observation,
792            DecayFlags::default(),
793            &cfg,
794        );
795        // ±1 fixed-point step of drift acceptable.
796        let target = i32::from(stored.as_u16()) / 2;
797        let actual = i32::from(eff.as_u16());
798        assert!(
799            (actual - target).abs() <= 1,
800            "expected ≈{target}, got {actual}"
801        );
802    }
803
804    #[test]
805    fn defaults_match_spec_table() {
806        let cfg = DecayConfig::librarian_defaults();
807        // Spot-check the three distinctive rows: profile (730d),
808        // pending_verification (30d), librarian_assignment (∞).
809        assert_eq!(cfg.sem_profile, HalfLife::from_days(730));
810        assert_eq!(cfg.sem_pending_verification, HalfLife::from_days(30));
811        assert_eq!(cfg.sem_librarian_assignment, HalfLife::no_decay());
812        assert_eq!(cfg.epi_self_report, HalfLife::from_days(30));
813        assert_eq!(cfg.pro_any, HalfLife::no_decay());
814    }
815
816    // ----- TOML loader -----
817
818    #[test]
819    fn toml_empty_input_preserves_defaults() {
820        let cfg = DecayConfig::from_toml("").expect("parse");
821        assert_eq!(cfg, DecayConfig::librarian_defaults());
822    }
823
824    #[test]
825    fn toml_overrides_semantic_half_lives() {
826        let toml = r"
827            [decay.semantic]
828            profile = 30
829            observation = 365
830        ";
831        let cfg = DecayConfig::from_toml(toml).expect("parse");
832        assert_eq!(cfg.sem_profile, HalfLife::from_days(30));
833        assert_eq!(cfg.sem_observation, HalfLife::from_days(365));
834        // Non-overridden keys preserved.
835        assert_eq!(cfg.sem_document, HalfLife::from_days(365)); // default
836    }
837
838    #[test]
839    fn toml_zero_encodes_no_decay() {
840        // Spec § 5.3 — 0 in mimir.toml = ∞ (NO_DECAY internally).
841        let toml = r"
842            [decay.semantic]
843            librarian_assignment = 0
844            profile = 0
845        ";
846        let cfg = DecayConfig::from_toml(toml).expect("parse");
847        assert_eq!(cfg.sem_librarian_assignment, HalfLife::no_decay());
848        assert_eq!(cfg.sem_profile, HalfLife::no_decay());
849    }
850
851    #[test]
852    fn toml_unknown_keys_are_ignored() {
853        let toml = r"
854            [decay.semantic]
855            profile = 30
856            future_source_kind = 42  # not in the v1 registry — must be ignored
857
858            [decay.not_a_real_section]
859            key = 1
860        ";
861        let cfg = DecayConfig::from_toml(toml).expect("parse");
862        assert_eq!(cfg.sem_profile, HalfLife::from_days(30));
863    }
864
865    #[test]
866    fn toml_rejects_negative_days() {
867        let toml = r"
868            [decay.semantic]
869            profile = -1
870        ";
871        let err = DecayConfig::from_toml(toml).expect_err("negative");
872        assert!(matches!(err, DecayConfigError::InvalidDays { .. }));
873    }
874
875    #[test]
876    fn toml_rejects_non_integer_values() {
877        let toml = r#"
878            [decay.semantic]
879            profile = "thirty"
880        "#;
881        let err = DecayConfig::from_toml(toml).expect_err("string");
882        assert!(matches!(
883            err,
884            DecayConfigError::ExpectedNonNegInteger { .. }
885        ));
886    }
887
888    #[test]
889    fn toml_rejects_wrong_section_type() {
890        let toml = r"
891            decay = 42
892        ";
893        let err = DecayConfig::from_toml(toml).expect_err("not a table");
894        assert!(matches!(
895            err,
896            DecayConfigError::ExpectedTable { path: "decay" }
897        ));
898    }
899
900    #[test]
901    fn toml_overrides_episodic_and_procedural() {
902        let toml = r"
903            [decay.episodic]
904            observation = 7
905            self_report = 3
906            participant_report = 14
907
908            [decay.procedural]
909            any = 365
910        ";
911        let cfg = DecayConfig::from_toml(toml).expect("parse");
912        assert_eq!(cfg.epi_observation, HalfLife::from_days(7));
913        assert_eq!(cfg.epi_self_report, HalfLife::from_days(3));
914        assert_eq!(cfg.epi_participant_report, HalfLife::from_days(14));
915        assert_eq!(cfg.pro_any, HalfLife::from_days(365));
916    }
917
918    #[test]
919    fn apply_toml_is_additive() {
920        // Multiple calls stack; later values win.
921        let mut cfg = DecayConfig::librarian_defaults();
922        cfg.apply_toml("[decay.semantic]\nprofile = 30")
923            .expect("first");
924        assert_eq!(cfg.sem_profile, HalfLife::from_days(30));
925        cfg.apply_toml("[decay.semantic]\nobservation = 7")
926            .expect("second");
927        // First override preserved; second override applied.
928        assert_eq!(cfg.sem_profile, HalfLife::from_days(30));
929        assert_eq!(cfg.sem_observation, HalfLife::from_days(7));
930    }
931
932    #[test]
933    fn toml_reload_changes_effective_confidence_without_restart() {
934        // Spec § 1 graduation criterion #4 + § 13 invariant 5: user
935        // config overrides take effect at runtime.
936        let mut cfg = DecayConfig::librarian_defaults();
937        let stored = c(1.0);
938        let elapsed = 30 * DAY_MS;
939
940        let before = effective_confidence(
941            stored,
942            elapsed,
943            MemoryKindTag::Semantic,
944            SourceKind::Observation,
945            DecayFlags::default(),
946            &cfg,
947        );
948
949        // Simulate an mimir.toml reload that shortens the half-life
950        // dramatically. The same (stored, elapsed) must now produce a
951        // lower effective confidence.
952        cfg.apply_toml("[decay.semantic]\nobservation = 1")
953            .expect("reload");
954        let after = effective_confidence(
955            stored,
956            elapsed,
957            MemoryKindTag::Semantic,
958            SourceKind::Observation,
959            DecayFlags::default(),
960            &cfg,
961        );
962        assert!(
963            after < before,
964            "reload did not accelerate decay: before={before:?} after={after:?}"
965        );
966    }
967
968    #[test]
969    fn user_override_takes_effect_at_runtime() {
970        // Spec § 13 invariant 5 ("user sovereignty") — overriding a
971        // half-life in-memory changes subsequent effective-confidence
972        // computations without any re-initialization.
973        let mut cfg = DecayConfig::librarian_defaults();
974        let stored = c(1.0);
975        // Baseline: 180 days Semantic@observation → approximately half.
976        let baseline = effective_confidence(
977            stored,
978            180 * DAY_MS,
979            MemoryKindTag::Semantic,
980            SourceKind::Observation,
981            DecayFlags::default(),
982            &cfg,
983        );
984        // Override the half-life to 90 days — 180 days is now 2 HLs
985        // (decay factor ≈ 0.25) rather than 1 HL.
986        cfg.sem_observation = HalfLife::from_days(90);
987        let overridden = effective_confidence(
988            stored,
989            180 * DAY_MS,
990            MemoryKindTag::Semantic,
991            SourceKind::Observation,
992            DecayFlags::default(),
993            &cfg,
994        );
995        assert!(
996            overridden < baseline,
997            "override should accelerate decay; baseline={baseline:?} overridden={overridden:?}"
998        );
999    }
1000}