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// ── Per-kind decay curves (memory federation P1) ─────────────────────────
28// One lifecycle, kind-appropriate dynamics: behavioral rules iterate fast
29// and must decay fast; environment facts stay true for a long time.
30
31/// Default half-life multiplier for `kind=rule` notes.
32pub const NOTE_RULE_HALF_LIFE_FACTOR: f64 = 0.5;
33/// Default half-life multiplier for `kind=fact` notes.
34pub const NOTE_FACT_HALF_LIFE_FACTOR: f64 = 2.0;
35
36/// The two knowledge shapes a `Category::Note` skill can carry.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum NoteKind {
39    /// Behavioral guidance — short half-life, fast iteration.
40    Rule,
41    /// Semantic statement about the environment — long half-life.
42    Fact,
43}
44
45impl NoteKind {
46    /// Compile-time default decay multiplier for this kind. The lifecycle
47    /// sweep applies the config-overridable values from
48    /// [`LifecycleThresholds`]; retrieval-side decay uses these defaults so
49    /// the two decay systems agree unless deliberately tuned apart.
50    pub fn default_half_life_factor(self) -> f64 {
51        match self {
52            NoteKind::Rule => NOTE_RULE_HALF_LIFE_FACTOR,
53            NoteKind::Fact => NOTE_FACT_HALF_LIFE_FACTOR,
54        }
55    }
56}
57
58/// Kind of a note manifest: `Category::Note` + a `rule` tag → `Rule`; a plain
59/// note is a `Fact` (facts are the default larval form; rules are explicit).
60/// Non-note skills have no kind.
61pub fn note_kind(manifest: &crate::skill::SkillManifest) -> Option<NoteKind> {
62    if manifest.category != crate::skill::Category::Note {
63        return None;
64    }
65    if manifest.tags.iter().any(|t| t == "rule") {
66        Some(NoteKind::Rule)
67    } else {
68        Some(NoteKind::Fact)
69    }
70}
71
72/// Decay half-life multiplier for `manifest` under thresholds `t` — notes get
73/// per-kind curves; everything else (and a missing manifest) is 1.0.
74pub fn half_life_factor_for(
75    manifest: Option<&crate::skill::SkillManifest>,
76    t: &LifecycleThresholds,
77) -> f64 {
78    match manifest.and_then(note_kind) {
79        Some(NoteKind::Rule) => t.note_rule_half_life_factor,
80        Some(NoteKind::Fact) => t.note_fact_half_life_factor,
81        None => 1.0,
82    }
83}
84
85/// Runtime-immutable lifecycle thresholds, derived from `SkillLifecycleConfig`.
86///
87/// Created once per sweep and threaded through to `next_state`. The `Default`
88/// impl mirrors the compile-time constants below so callers that don't have
89/// access to config (e.g. the doctor's read-only preview) continue to work
90/// without any config file.
91#[derive(Debug, Clone)]
92pub struct LifecycleThresholds {
93    pub promote_draft_uses: u64,
94    pub promote_emerging_uses: u64,
95    pub promote_emerging_success_rate: f64,
96    pub promote_emerging_age_days: i64,
97    pub promote_stable_uses: u64,
98    pub promote_stable_success_rate: f64,
99    pub promote_stable_age_days: i64,
100    pub demote_emerging_uses: u64,
101    pub demote_emerging_success_rate: f64,
102    pub demote_stable_uses: u64,
103    pub demote_stable_success_rate: f64,
104    pub deprecated_success_rate: f64,
105    pub deprecated_no_success_days: i64,
106    pub auto_archive_confidence: f64,
107    pub auto_archive_age_days: i64,
108    /// Per-kind decay multipliers (federation P1). See [`half_life_factor_for`].
109    pub note_rule_half_life_factor: f64,
110    pub note_fact_half_life_factor: f64,
111}
112
113impl Default for LifecycleThresholds {
114    fn default() -> Self {
115        Self {
116            promote_draft_uses: PROMOTE_DRAFT_USES,
117            promote_emerging_uses: PROMOTE_EMERGING_USES,
118            promote_emerging_success_rate: PROMOTE_EMERGING_SUCCESS_RATE,
119            promote_emerging_age_days: PROMOTE_EMERGING_AGE_DAYS,
120            promote_stable_uses: PROMOTE_STABLE_USES,
121            promote_stable_success_rate: PROMOTE_STABLE_SUCCESS_RATE,
122            promote_stable_age_days: PROMOTE_STABLE_AGE_DAYS,
123            demote_emerging_uses: DEMOTE_EMERGING_USES,
124            demote_emerging_success_rate: DEMOTE_EMERGING_SUCCESS_RATE,
125            demote_stable_uses: DEMOTE_STABLE_USES,
126            demote_stable_success_rate: DEMOTE_STABLE_SUCCESS_RATE,
127            deprecated_success_rate: DEPRECATED_SUCCESS_RATE,
128            deprecated_no_success_days: DEPRECATED_NO_SUCCESS_DAYS,
129            auto_archive_confidence: AUTO_ARCHIVE_CONFIDENCE,
130            auto_archive_age_days: AUTO_ARCHIVE_AGE_DAYS,
131            note_rule_half_life_factor: NOTE_RULE_HALF_LIFE_FACTOR,
132            note_fact_half_life_factor: NOTE_FACT_HALF_LIFE_FACTOR,
133        }
134    }
135}
136
137impl From<&SkillLifecycleConfig> for LifecycleThresholds {
138    fn from(c: &SkillLifecycleConfig) -> Self {
139        Self {
140            promote_draft_uses: c.promote_draft_uses,
141            promote_emerging_uses: c.promote_emerging_uses,
142            promote_emerging_success_rate: c.promote_emerging_success_rate,
143            promote_emerging_age_days: c.promote_emerging_age_days,
144            promote_stable_uses: c.promote_stable_uses,
145            promote_stable_success_rate: c.promote_stable_success_rate,
146            promote_stable_age_days: c.promote_stable_age_days,
147            demote_emerging_uses: c.demote_emerging_uses,
148            demote_emerging_success_rate: c.demote_emerging_success_rate,
149            demote_stable_uses: c.demote_stable_uses,
150            demote_stable_success_rate: c.demote_stable_success_rate,
151            deprecated_success_rate: c.deprecated_success_rate,
152            deprecated_no_success_days: c.deprecated_no_success_days,
153            auto_archive_confidence: c.auto_archive_confidence,
154            auto_archive_age_days: c.auto_archive_age_days,
155            note_rule_half_life_factor: c.note_rule_half_life_factor,
156            note_fact_half_life_factor: c.note_fact_half_life_factor,
157        }
158    }
159}
160
161/// Promotion thresholds — values that MUST be exceeded.
162pub const PROMOTE_DRAFT_USES: u64 = 3;
163pub const PROMOTE_EMERGING_USES: u64 = 10;
164pub const PROMOTE_EMERGING_SUCCESS_RATE: f64 = 0.6;
165pub const PROMOTE_EMERGING_AGE_DAYS: i64 = 7;
166pub const PROMOTE_STABLE_USES: u64 = 30;
167pub const PROMOTE_STABLE_SUCCESS_RATE: f64 = 0.8;
168pub const PROMOTE_STABLE_AGE_DAYS: i64 = 30;
169
170/// Demotion thresholds — values that MUST drop BELOW. Hysteresis: lower
171/// than the symmetric promotion threshold to prevent flap.
172pub const DEMOTE_EMERGING_USES: u64 = 8;
173pub const DEMOTE_EMERGING_SUCCESS_RATE: f64 = 0.55;
174pub const DEMOTE_STABLE_USES: u64 = 25;
175pub const DEMOTE_STABLE_SUCCESS_RATE: f64 = 0.75;
176pub const DEPRECATED_SUCCESS_RATE: f64 = 0.3;
177pub const DEPRECATED_NO_SUCCESS_DAYS: i64 = 90;
178
179/// Compute decayed confidence given an anchor, last success time, and
180/// the half-life for the current lifecycle state.
181pub fn calculate_decay(
182    anchor_confidence: f64,
183    last_success: Option<DateTime<Utc>>,
184    half_life_days: f64,
185    now: DateTime<Utc>,
186) -> f64 {
187    let conf = anchor_confidence.clamp(0.0, 1.0);
188    if !conf.is_finite() || half_life_days <= 0.0 {
189        return MIN_CONFIDENCE;
190    }
191    let last = match last_success {
192        None => return MIN_CONFIDENCE,
193        Some(t) => t.min(now), // clock-skew defence
194    };
195    let days = (now - last).num_seconds() as f64 / 86_400.0;
196    if days <= 0.0 {
197        return conf;
198    }
199    (conf * 0.5_f64.powf(days / half_life_days)).max(MIN_CONFIDENCE)
200}
201
202/// Compute what state the skill *should* be in given its current stats
203/// and the current time. PURE — does not mutate. Idempotent: calling
204/// this twice with the same inputs returns the same output.
205///
206/// Caller (M5b sweep, or M5a doctor preview) decides whether to
207/// persist or merely display the result.
208///
209/// Pass `&LifecycleThresholds::default()` when config is not available
210/// (e.g. doctor read-only preview).
211pub fn next_state(
212    stats: &SkillStats,
213    now: DateTime<Utc>,
214    t: &LifecycleThresholds,
215) -> LifecycleState {
216    let current = stats.lifecycle_state;
217
218    // Destroyed is terminal — the files are gone; the sweep never calls
219    // next_state for destroyed skills, but guard defensively.
220    if current == LifecycleState::Destroyed {
221        return LifecycleState::Destroyed;
222    }
223
224    // Hard archive condition (overrides everything except pinned).
225    if !stats.pinned {
226        let decayed = calculate_decay(
227            stats.anchor_confidence,
228            stats.last_success_at,
229            half_life_days(current),
230            now,
231        );
232        if let Some(first_ok) = stats.first_successful_use_at {
233            let age_days = (now - first_ok).num_days();
234            if decayed < t.auto_archive_confidence && age_days > t.auto_archive_age_days {
235                return LifecycleState::Archived;
236            }
237        }
238    }
239
240    let success_rate = if stats.usage_count == 0 {
241        0.0
242    } else {
243        stats.success_count as f64 / stats.usage_count as f64
244    };
245    let age_days = stats
246        .first_successful_use_at
247        .map(|t| (now - t).num_days())
248        .unwrap_or(0);
249    let no_success_days = stats
250        .last_success_at
251        .map(|ts| (now - ts).num_days())
252        .unwrap_or(i64::MAX);
253
254    // Deprecation predicate — applies from any non-Archived state.
255    if !stats.pinned
256        && current != LifecycleState::Archived
257        && (success_rate < t.deprecated_success_rate && stats.usage_count >= 5
258            || no_success_days > t.deprecated_no_success_days)
259    {
260        return LifecycleState::Deprecated;
261    }
262
263    // Promotion ladder. Each rung requires the prior rung's criteria.
264    let can_canonical = stats.pinned
265        && stats.success_count >= t.promote_stable_uses
266        && success_rate >= t.promote_stable_success_rate
267        && age_days >= t.promote_stable_age_days;
268    let can_stable = stats.success_count >= t.promote_emerging_uses
269        && success_rate >= t.promote_emerging_success_rate
270        && age_days >= t.promote_emerging_age_days;
271    let can_emerging = stats.success_count >= t.promote_draft_uses;
272
273    if can_canonical {
274        LifecycleState::Canonical
275    } else if can_stable {
276        LifecycleState::Stable
277    } else if can_emerging {
278        LifecycleState::Emerging
279    } else {
280        LifecycleState::Draft
281    }
282}
283
284/// Cap a proposed lifecycle state for LLM-authored, uncurated skills.
285///
286/// PURE. The promotion ladder (`next_state`) is provenance-blind; this
287/// applies the A1 curation gate on top: an `Llm` skill that no human has
288/// curated cannot rise above `Emerging`, no matter how good its run stats
289/// look. `Human`/`Hybrid` skills, curated skills, and a disabled gate all
290/// pass `proposed` through unchanged. States at or below `Emerging` are
291/// never raised.
292/// Whether decay may demote this item.
293///
294/// Decay arrived on 2026-02-25 as "Pattern Maturity + Automatic Decay" — the
295/// filter that made *automatic mining* survivable, because most of what a miner
296/// produces is noise. The pattern pipeline was removed in #404 and notes
297/// inherited the machinery, but not the condition it depended on.
298///
299/// The axis is not human-versus-machine, which conflates MUR's own shipped
300/// builtins with what you wrote: deprecating a builtin you never use is
301/// correct, and `mur sync` puts it back. The axis is **replaceability**. Decay
302/// ends at `Archived`, `Archived` ends at `remove_dir_all`, and that is
303/// survivable only for content MUR can reinstall.
304///
305/// So: machine proposals decay wherever they came from, MUR-published content
306/// decays because it is recoverable, and everything else — a `mur notes create`
307/// note, an agent memory, a skill you authored — does not.
308///
309/// Demotion only. Evidence of actual failure (the broken-workflow fast path)
310/// still demotes anything, because that is a measurement rather than a guess
311/// about staleness.
312pub fn decay_may_demote(publisher: &str, provenance: Provenance, curated: bool) -> bool {
313    // A machine proposal is noise until something proves otherwise, whoever
314    // published it.
315    if provenance == Provenance::Llm && !curated {
316        return true;
317    }
318    crate::skill::types::is_mur_owned_publisher(publisher)
319}
320
321pub fn cap_for_provenance(
322    proposed: LifecycleState,
323    provenance: Provenance,
324    curated: bool,
325    gate_enabled: bool,
326) -> LifecycleState {
327    let gated = gate_enabled && provenance == Provenance::Llm && !curated;
328    if gated && lifecycle_rank(proposed) > lifecycle_rank(LifecycleState::Emerging) {
329        LifecycleState::Emerging
330    } else {
331        proposed
332    }
333}
334
335/// Returns true if the transition from `from` to `to` may be persisted
336/// *right now*. Even when `next_state` says a transition is warranted,
337/// this guard prevents:
338///   - flap within MIN_DWELL_HOURS of the last transition
339///   - downward transitions for pinned skills below their pinned tier
340///   - hysteresis bounce around exact thresholds
341pub fn transition_allowed(
342    from: LifecycleState,
343    to: LifecycleState,
344    stats: &SkillStats,
345    now: DateTime<Utc>,
346) -> bool {
347    if from == to {
348        return false;
349    }
350    if stats.pinned && lifecycle_rank(to) < lifecycle_rank(from) {
351        return false;
352    }
353    let elapsed = now - stats.lifecycle_changed_at;
354    if elapsed < Duration::hours(MIN_DWELL_HOURS) {
355        return false;
356    }
357    true
358}
359
360/// Total order over lifecycle states. Public because the federation snapshot
361/// floor (mur-core) compares against the same ranking — a duplicated table
362/// drifting from this one would silently change what federates.
363pub fn lifecycle_rank(s: LifecycleState) -> u8 {
364    match s {
365        LifecycleState::Destroyed => 0,
366        LifecycleState::Archived => 1,
367        LifecycleState::Deprecated => 2,
368        LifecycleState::Draft => 3,
369        LifecycleState::Emerging => 4,
370        LifecycleState::Stable => 5,
371        LifecycleState::Canonical => 6,
372    }
373}
374
375/// Called by the M5b sweep AFTER persisting a promotion. Resets the
376/// confidence anchor so the new half-life applies from current, not
377/// stale, confidence. Without this, a skill promoted from Draft to
378/// Emerging would carry its already-decayed anchor under the longer
379/// Emerging half-life and appear artificially fresh forever.
380///
381/// M5b's sweep MUST call this after writing the new `lifecycle_state`
382/// to disk. M5a never calls it.
383pub fn on_promotion(stats: &mut SkillStats, now: DateTime<Utc>) {
384    let prior_half_life = half_life_days(stats.lifecycle_state);
385    let decayed = calculate_decay(
386        stats.anchor_confidence,
387        stats.last_success_at,
388        prior_half_life,
389        now,
390    );
391    stats.anchor_confidence = decayed;
392    stats.lifecycle_changed_at = now;
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use chrono::TimeZone;
399
400    fn make_stats(
401        state: LifecycleState,
402        usage: u64,
403        success: u64,
404        first_ok_days_ago: i64,
405        last_ok_days_ago: i64,
406        anchor: f64,
407        pinned: bool,
408    ) -> SkillStats {
409        let now = Utc::now();
410        SkillStats {
411            schema_version: 1,
412            skill_name: "test".into(),
413            skill_version: "1.0.0".into(),
414            manifest_digest: "abc".into(),
415            lifecycle_state: state,
416            lifecycle_changed_at: now - Duration::hours(48),
417            pinned,
418            pinned_reason: String::new(),
419            usage_count: usage,
420            success_count: success,
421            failure_count: usage.saturating_sub(success),
422            last_used_at: Some(now - Duration::days(last_ok_days_ago)),
423            last_success_at: Some(now - Duration::days(last_ok_days_ago)),
424            first_successful_use_at: Some(now - Duration::days(first_ok_days_ago)),
425            anchor_confidence: anchor,
426            rebuilt_from_trace_through: None,
427            resolution_misses: 0,
428            curated_at: None,
429        }
430    }
431
432    #[test]
433    fn decay_floor_honored_at_extreme_age() {
434        let now = Utc::now();
435        let last = Some(now - Duration::days(10_000));
436        let conf = calculate_decay(1.0, last, 14.0, now);
437        assert_eq!(conf, MIN_CONFIDENCE);
438    }
439
440    #[test]
441    fn clock_skew_clamped_returns_anchor_unchanged() {
442        let now = Utc::now();
443        let future = now + Duration::days(1);
444        let conf = calculate_decay(0.8, Some(future), 14.0, now);
445        assert_eq!(conf, 0.8);
446    }
447
448    #[test]
449    fn decay_no_last_success_returns_min() {
450        let now = Utc::now();
451        let conf = calculate_decay(1.0, None, 14.0, now);
452        assert_eq!(conf, MIN_CONFIDENCE);
453    }
454
455    #[test]
456    fn next_state_idempotent() {
457        let now = Utc::now();
458        let stats = make_stats(LifecycleState::Draft, 1, 1, 1, 0, 1.0, false);
459        let s1 = next_state(&stats, now, &LifecycleThresholds::default());
460        let s2 = next_state(&stats, now, &LifecycleThresholds::default());
461        assert_eq!(s1, s2);
462    }
463
464    #[test]
465    fn promotion_full_ladder() {
466        let now = Utc::now();
467        // Enough successes, age, and rate to reach Canonical (with pin)
468        let stats = make_stats(LifecycleState::Draft, 50, 45, 40, 0, 1.0, true);
469        assert_eq!(
470            next_state(&stats, now, &LifecycleThresholds::default()),
471            LifecycleState::Canonical
472        );
473    }
474
475    #[test]
476    fn emerging_without_pin() {
477        let now = Utc::now();
478        let stats = make_stats(LifecycleState::Draft, 5, 4, 10, 1, 1.0, false);
479        // 5 successes ≥ PROMOTE_DRAFT_USES=3, but not enough age for Stable
480        assert_eq!(
481            next_state(&stats, now, &LifecycleThresholds::default()),
482            LifecycleState::Emerging
483        );
484    }
485
486    #[test]
487    fn deprecation_from_low_success_rate() {
488        let now = Utc::now();
489        let stats = make_stats(LifecycleState::Emerging, 10, 2, 30, 10, 0.5, false);
490        // success_rate = 0.2 < 0.3, usage >= 5
491        assert_eq!(
492            next_state(&stats, now, &LifecycleThresholds::default()),
493            LifecycleState::Deprecated
494        );
495    }
496
497    #[test]
498    fn pinned_floor_prevents_demotion() {
499        let now_fixed = Utc.with_ymd_and_hms(2026, 5, 25, 0, 0, 0).unwrap();
500        // Bad metrics would normally demote, but pinned
501        let stats = SkillStats {
502            lifecycle_state: LifecycleState::Stable,
503            pinned: true,
504            usage_count: 10,
505            success_count: 2,
506            failure_count: 8,
507            anchor_confidence: 0.5,
508            last_success_at: Some(now_fixed - Duration::days(120)),
509            first_successful_use_at: Some(now_fixed),
510            lifecycle_changed_at: now_fixed - Duration::hours(48),
511            ..make_stats(LifecycleState::Stable, 10, 2, 30, 120, 0.5, true)
512        };
513        // Pinned: should not deprecate despite terrible metrics
514        let state = next_state(&stats, now_fixed, &LifecycleThresholds::default());
515        assert_ne!(state, LifecycleState::Deprecated);
516    }
517
518    #[test]
519    fn transition_allowed_dwell_within_24h_returns_false() {
520        let now = Utc::now();
521        let stats = SkillStats {
522            lifecycle_changed_at: now - Duration::hours(1),
523            pinned: false,
524            ..make_stats(LifecycleState::Draft, 0, 0, 0, 0, 1.0, false)
525        };
526        assert!(!transition_allowed(
527            LifecycleState::Draft,
528            LifecycleState::Emerging,
529            &stats,
530            now,
531        ));
532    }
533
534    #[test]
535    fn transition_allowed_identical_from_to_returns_false() {
536        let now = Utc::now();
537        let stats = make_stats(LifecycleState::Draft, 0, 0, 0, 0, 1.0, false);
538        assert!(!transition_allowed(
539            LifecycleState::Draft,
540            LifecycleState::Draft,
541            &stats,
542            now,
543        ));
544    }
545
546    #[test]
547    fn transition_allowed_downgrade_pinned_blocked() {
548        let now = Utc::now();
549        let stats = SkillStats {
550            lifecycle_changed_at: now - Duration::hours(48),
551            pinned: true,
552            ..make_stats(LifecycleState::Stable, 0, 0, 0, 0, 1.0, true)
553        };
554        assert!(!transition_allowed(
555            LifecycleState::Stable,
556            LifecycleState::Emerging,
557            &stats,
558            now,
559        ));
560    }
561
562    #[test]
563    fn on_promotion_resets_anchor() {
564        let now = Utc::now();
565        let mut stats = make_stats(LifecycleState::Draft, 0, 0, 0, 0, 1.0, false);
566        let old_anchor = stats.anchor_confidence;
567        on_promotion(&mut stats, now);
568        // Anchor should be recalculated; lifecycle_changed_at updated
569        assert!(stats.lifecycle_changed_at >= now - Duration::seconds(1));
570        // Decayed value from a 1.0 anchor with 0 successes and no last_success
571        // → MIN_CONFIDENCE since last_success is None
572        assert!(stats.anchor_confidence <= old_anchor);
573    }
574
575    #[test]
576    fn cap_blocks_llm_uncurated_above_emerging() {
577        // Stable proposed, LLM, not curated, gate on → capped to Emerging.
578        assert_eq!(
579            cap_for_provenance(LifecycleState::Stable, Provenance::Llm, false, true),
580            LifecycleState::Emerging
581        );
582        // Canonical likewise capped.
583        assert_eq!(
584            cap_for_provenance(LifecycleState::Canonical, Provenance::Llm, false, true),
585            LifecycleState::Emerging
586        );
587    }
588
589    #[test]
590    fn cap_is_noop_for_human_curated_or_disabled() {
591        // Human authorship → never gated.
592        assert_eq!(
593            cap_for_provenance(LifecycleState::Stable, Provenance::Human, false, true),
594            LifecycleState::Stable
595        );
596        // LLM but curated → gate open.
597        assert_eq!(
598            cap_for_provenance(LifecycleState::Stable, Provenance::Llm, true, true),
599            LifecycleState::Stable
600        );
601        // Gate disabled by config → no cap.
602        assert_eq!(
603            cap_for_provenance(LifecycleState::Canonical, Provenance::Llm, false, false),
604            LifecycleState::Canonical
605        );
606        // At or below Emerging → unchanged even when gated.
607        assert_eq!(
608            cap_for_provenance(LifecycleState::Draft, Provenance::Llm, false, true),
609            LifecycleState::Draft
610        );
611    }
612}
613
614#[cfg(test)]
615mod note_kind_tests {
616    use super::*;
617
618    fn manifest(category: &str, tags: &str) -> crate::skill::SkillManifest {
619        crate::skill::parse_canonical(&format!(
620            "name: t\nversion: 1.0.0\npublisher: human:t\ndescription: d\ncategory: {category}\ntags: {tags}\ncontent:\n  abstract: a\n  context: c\n"
621        ))
622        .unwrap()
623    }
624
625    #[test]
626    fn note_kind_rule_fact_and_none() {
627        assert_eq!(note_kind(&manifest("note", "[rule]")), Some(NoteKind::Rule));
628        assert_eq!(note_kind(&manifest("note", "[]")), Some(NoteKind::Fact));
629        assert_eq!(note_kind(&manifest("context", "[rule]")), None);
630    }
631
632    #[test]
633    fn half_life_factor_rule_halves_fact_doubles_skill_unchanged() {
634        let t = LifecycleThresholds::default();
635        assert!(half_life_factor_for(Some(&manifest("note", "[rule]")), &t) < 1.0);
636        assert!(half_life_factor_for(Some(&manifest("note", "[]")), &t) > 1.0);
637        assert_eq!(half_life_factor_for(None, &t), 1.0);
638        assert_eq!(
639            half_life_factor_for(Some(&manifest("context", "[]")), &t),
640            1.0
641        );
642    }
643}