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