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