Skip to main content

mur_common/skill/
lifecycle.rs

1//! Pure-function lifecycle + decay layer. Functions take inputs, return
2//! outputs, never touch disk. M5b's sweep calls these to decide
3//! transitions and persist; M5a's doctor calls them for read-only display.
4
5use chrono::{DateTime, Duration, Utc};
6
7use crate::skill::stats::{LifecycleState, SkillStats};
8
9pub const MIN_CONFIDENCE: f64 = 0.05;
10pub const AUTO_ARCHIVE_CONFIDENCE: f64 = 0.10;
11pub const AUTO_ARCHIVE_AGE_DAYS: i64 = 180;
12pub const MIN_DWELL_HOURS: i64 = 24;
13
14/// Half-life (days) for confidence decay, indexed by current state.
15pub fn half_life_days(state: LifecycleState) -> f64 {
16    match state {
17        LifecycleState::Draft => 14.0,
18        LifecycleState::Emerging => 90.0,
19        LifecycleState::Stable => 365.0,
20        LifecycleState::Canonical => 730.0,
21        LifecycleState::Deprecated | LifecycleState::Archived => 365.0,
22    }
23}
24
25/// Promotion thresholds — values that MUST be exceeded.
26pub const PROMOTE_DRAFT_USES: u64 = 3;
27pub const PROMOTE_EMERGING_USES: u64 = 10;
28pub const PROMOTE_EMERGING_SUCCESS_RATE: f64 = 0.6;
29pub const PROMOTE_EMERGING_AGE_DAYS: i64 = 7;
30pub const PROMOTE_STABLE_USES: u64 = 30;
31pub const PROMOTE_STABLE_SUCCESS_RATE: f64 = 0.8;
32pub const PROMOTE_STABLE_AGE_DAYS: i64 = 30;
33
34/// Demotion thresholds — values that MUST drop BELOW. Hysteresis: lower
35/// than the symmetric promotion threshold to prevent flap.
36pub const DEMOTE_EMERGING_USES: u64 = 8;
37pub const DEMOTE_EMERGING_SUCCESS_RATE: f64 = 0.55;
38pub const DEMOTE_STABLE_USES: u64 = 25;
39pub const DEMOTE_STABLE_SUCCESS_RATE: f64 = 0.75;
40pub const DEPRECATED_SUCCESS_RATE: f64 = 0.3;
41pub const DEPRECATED_NO_SUCCESS_DAYS: i64 = 90;
42
43/// Compute decayed confidence given an anchor, last success time, and
44/// the half-life for the current lifecycle state.
45pub fn calculate_decay(
46    anchor_confidence: f64,
47    last_success: Option<DateTime<Utc>>,
48    half_life_days: f64,
49    now: DateTime<Utc>,
50) -> f64 {
51    let conf = anchor_confidence.clamp(0.0, 1.0);
52    if !conf.is_finite() || half_life_days <= 0.0 {
53        return MIN_CONFIDENCE;
54    }
55    let last = match last_success {
56        None => return MIN_CONFIDENCE,
57        Some(t) => t.min(now), // clock-skew defence
58    };
59    let days = (now - last).num_seconds() as f64 / 86_400.0;
60    if days <= 0.0 {
61        return conf;
62    }
63    (conf * 0.5_f64.powf(days / half_life_days)).max(MIN_CONFIDENCE)
64}
65
66/// Compute what state the skill *should* be in given its current stats
67/// and the current time. PURE — does not mutate. Idempotent: calling
68/// this twice with the same inputs returns the same output.
69///
70/// Caller (M5b sweep, or M5a doctor preview) decides whether to
71/// persist or merely display the result.
72pub fn next_state(stats: &SkillStats, now: DateTime<Utc>) -> LifecycleState {
73    let current = stats.lifecycle_state;
74
75    // Hard archive condition (overrides everything except pinned).
76    if !stats.pinned {
77        let decayed = calculate_decay(
78            stats.anchor_confidence,
79            stats.last_success_at,
80            half_life_days(current),
81            now,
82        );
83        if let Some(first_ok) = stats.first_successful_use_at {
84            let age_days = (now - first_ok).num_days();
85            if decayed < AUTO_ARCHIVE_CONFIDENCE && age_days > AUTO_ARCHIVE_AGE_DAYS {
86                return LifecycleState::Archived;
87            }
88        }
89    }
90
91    let success_rate = if stats.usage_count == 0 {
92        0.0
93    } else {
94        stats.success_count as f64 / stats.usage_count as f64
95    };
96    let age_days = stats
97        .first_successful_use_at
98        .map(|t| (now - t).num_days())
99        .unwrap_or(0);
100    let no_success_days = stats
101        .last_success_at
102        .map(|t| (now - t).num_days())
103        .unwrap_or(i64::MAX);
104
105    // Deprecation predicate — applies from any non-Archived state.
106    if !stats.pinned
107        && current != LifecycleState::Archived
108        && (success_rate < DEPRECATED_SUCCESS_RATE && stats.usage_count >= 5
109            || no_success_days > DEPRECATED_NO_SUCCESS_DAYS)
110    {
111        return LifecycleState::Deprecated;
112    }
113
114    // Promotion ladder. Each rung requires the prior rung's criteria.
115    let can_canonical = stats.pinned
116        && stats.success_count >= PROMOTE_STABLE_USES
117        && success_rate >= PROMOTE_STABLE_SUCCESS_RATE
118        && age_days >= PROMOTE_STABLE_AGE_DAYS;
119    let can_stable = stats.success_count >= PROMOTE_EMERGING_USES
120        && success_rate >= PROMOTE_EMERGING_SUCCESS_RATE
121        && age_days >= PROMOTE_EMERGING_AGE_DAYS;
122    let can_emerging = stats.success_count >= PROMOTE_DRAFT_USES;
123
124    if can_canonical {
125        LifecycleState::Canonical
126    } else if can_stable {
127        LifecycleState::Stable
128    } else if can_emerging {
129        LifecycleState::Emerging
130    } else {
131        LifecycleState::Draft
132    }
133}
134
135/// Returns true if the transition from `from` to `to` may be persisted
136/// *right now*. Even when `next_state` says a transition is warranted,
137/// this guard prevents:
138///   - flap within MIN_DWELL_HOURS of the last transition
139///   - downward transitions for pinned skills below their pinned tier
140///   - hysteresis bounce around exact thresholds
141pub fn transition_allowed(
142    from: LifecycleState,
143    to: LifecycleState,
144    stats: &SkillStats,
145    now: DateTime<Utc>,
146) -> bool {
147    if from == to {
148        return false;
149    }
150    if stats.pinned && rank(to) < rank(from) {
151        return false;
152    }
153    let elapsed = now - stats.lifecycle_changed_at;
154    if elapsed < Duration::hours(MIN_DWELL_HOURS) {
155        return false;
156    }
157    true
158}
159
160fn rank(s: LifecycleState) -> u8 {
161    match s {
162        LifecycleState::Archived => 0,
163        LifecycleState::Deprecated => 1,
164        LifecycleState::Draft => 2,
165        LifecycleState::Emerging => 3,
166        LifecycleState::Stable => 4,
167        LifecycleState::Canonical => 5,
168    }
169}
170
171/// Called by the M5b sweep AFTER persisting a promotion. Resets the
172/// confidence anchor so the new half-life applies from current, not
173/// stale, confidence. Without this, a skill promoted from Draft to
174/// Emerging would carry its already-decayed anchor under the longer
175/// Emerging half-life and appear artificially fresh forever.
176///
177/// M5b's sweep MUST call this after writing the new `lifecycle_state`
178/// to disk. M5a never calls it.
179pub fn on_promotion(stats: &mut SkillStats, now: DateTime<Utc>) {
180    let prior_half_life = half_life_days(stats.lifecycle_state);
181    let decayed = calculate_decay(
182        stats.anchor_confidence,
183        stats.last_success_at,
184        prior_half_life,
185        now,
186    );
187    stats.anchor_confidence = decayed;
188    stats.lifecycle_changed_at = now;
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use chrono::TimeZone;
195
196    fn make_stats(
197        state: LifecycleState,
198        usage: u64,
199        success: u64,
200        first_ok_days_ago: i64,
201        last_ok_days_ago: i64,
202        anchor: f64,
203        pinned: bool,
204    ) -> SkillStats {
205        let now = Utc::now();
206        SkillStats {
207            schema_version: 1,
208            skill_name: "test".into(),
209            skill_version: "1.0.0".into(),
210            manifest_digest: "abc".into(),
211            lifecycle_state: state,
212            lifecycle_changed_at: now - Duration::hours(48),
213            pinned,
214            pinned_reason: String::new(),
215            usage_count: usage,
216            success_count: success,
217            failure_count: usage.saturating_sub(success),
218            last_used_at: Some(now - Duration::days(last_ok_days_ago)),
219            last_success_at: Some(now - Duration::days(last_ok_days_ago)),
220            first_successful_use_at: Some(now - Duration::days(first_ok_days_ago)),
221            anchor_confidence: anchor,
222            rebuilt_from_trace_through: None,
223            resolution_misses: 0,
224        }
225    }
226
227    #[test]
228    fn decay_floor_honored_at_extreme_age() {
229        let now = Utc::now();
230        let last = Some(now - Duration::days(10_000));
231        let conf = calculate_decay(1.0, last, 14.0, now);
232        assert_eq!(conf, MIN_CONFIDENCE);
233    }
234
235    #[test]
236    fn clock_skew_clamped_returns_anchor_unchanged() {
237        let now = Utc::now();
238        let future = now + Duration::days(1);
239        let conf = calculate_decay(0.8, Some(future), 14.0, now);
240        assert_eq!(conf, 0.8);
241    }
242
243    #[test]
244    fn decay_no_last_success_returns_min() {
245        let now = Utc::now();
246        let conf = calculate_decay(1.0, None, 14.0, now);
247        assert_eq!(conf, MIN_CONFIDENCE);
248    }
249
250    #[test]
251    fn next_state_idempotent() {
252        let now = Utc::now();
253        let stats = make_stats(LifecycleState::Draft, 1, 1, 1, 0, 1.0, false);
254        let s1 = next_state(&stats, now);
255        let s2 = next_state(&stats, now);
256        assert_eq!(s1, s2);
257    }
258
259    #[test]
260    fn promotion_full_ladder() {
261        let now = Utc::now();
262        // Enough successes, age, and rate to reach Canonical (with pin)
263        let stats = make_stats(LifecycleState::Draft, 50, 45, 40, 0, 1.0, true);
264        assert_eq!(next_state(&stats, now), LifecycleState::Canonical);
265    }
266
267    #[test]
268    fn emerging_without_pin() {
269        let now = Utc::now();
270        let stats = make_stats(LifecycleState::Draft, 5, 4, 10, 1, 1.0, false);
271        // 5 successes ≥ PROMOTE_DRAFT_USES=3, but not enough age for Stable
272        assert_eq!(next_state(&stats, now), LifecycleState::Emerging);
273    }
274
275    #[test]
276    fn deprecation_from_low_success_rate() {
277        let now = Utc::now();
278        let stats = make_stats(LifecycleState::Emerging, 10, 2, 30, 10, 0.5, false);
279        // success_rate = 0.2 < 0.3, usage >= 5
280        assert_eq!(next_state(&stats, now), LifecycleState::Deprecated);
281    }
282
283    #[test]
284    fn pinned_floor_prevents_demotion() {
285        let now_fixed = Utc.with_ymd_and_hms(2026, 5, 25, 0, 0, 0).unwrap();
286        // Bad metrics would normally demote, but pinned
287        let stats = SkillStats {
288            lifecycle_state: LifecycleState::Stable,
289            pinned: true,
290            usage_count: 10,
291            success_count: 2,
292            failure_count: 8,
293            anchor_confidence: 0.5,
294            last_success_at: Some(now_fixed - Duration::days(120)),
295            first_successful_use_at: Some(now_fixed),
296            lifecycle_changed_at: now_fixed - Duration::hours(48),
297            ..make_stats(LifecycleState::Stable, 10, 2, 30, 120, 0.5, true)
298        };
299        // Pinned: should not deprecate despite terrible metrics
300        let state = next_state(&stats, now_fixed);
301        assert_ne!(state, LifecycleState::Deprecated);
302    }
303
304    #[test]
305    fn transition_allowed_dwell_within_24h_returns_false() {
306        let now = Utc::now();
307        let stats = SkillStats {
308            lifecycle_changed_at: now - Duration::hours(1),
309            pinned: false,
310            ..make_stats(LifecycleState::Draft, 0, 0, 0, 0, 1.0, false)
311        };
312        assert!(!transition_allowed(
313            LifecycleState::Draft,
314            LifecycleState::Emerging,
315            &stats,
316            now,
317        ));
318    }
319
320    #[test]
321    fn transition_allowed_identical_from_to_returns_false() {
322        let now = Utc::now();
323        let stats = make_stats(LifecycleState::Draft, 0, 0, 0, 0, 1.0, false);
324        assert!(!transition_allowed(
325            LifecycleState::Draft,
326            LifecycleState::Draft,
327            &stats,
328            now,
329        ));
330    }
331
332    #[test]
333    fn transition_allowed_downgrade_pinned_blocked() {
334        let now = Utc::now();
335        let stats = SkillStats {
336            lifecycle_changed_at: now - Duration::hours(48),
337            pinned: true,
338            ..make_stats(LifecycleState::Stable, 0, 0, 0, 0, 1.0, true)
339        };
340        assert!(!transition_allowed(
341            LifecycleState::Stable,
342            LifecycleState::Emerging,
343            &stats,
344            now,
345        ));
346    }
347
348    #[test]
349    fn on_promotion_resets_anchor() {
350        let now = Utc::now();
351        let mut stats = make_stats(LifecycleState::Draft, 0, 0, 0, 0, 1.0, false);
352        let old_anchor = stats.anchor_confidence;
353        on_promotion(&mut stats, now);
354        // Anchor should be recalculated; lifecycle_changed_at updated
355        assert!(stats.lifecycle_changed_at >= now - Duration::seconds(1));
356        // Decayed value from a 1.0 anchor with 0 successes and no last_success
357        // → MIN_CONFIDENCE since last_success is None
358        assert!(stats.anchor_confidence <= old_anchor);
359    }
360}