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/// The injection guarantee a `Category::Note` skill carries.
73///
74/// Required means the note is injected every turn — a guarantee about
75/// PRESENCE in the prompt, not about model compliance.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum InjectionPolicy {
78    /// Permanent instruction: injected every turn, never ranked or truncated.
79    Required,
80    /// Remembered information: competes for the remaining budget.
81    BestEffort,
82}
83
84/// Injection policy of a note manifest: `Category::Note` + a `required` tag →
85/// `Required`; any other note is `BestEffort`. Non-note skills have none.
86///
87/// Tag-derived on purpose, mirroring [`note_kind`]: policy lives in
88/// `manifest.tags`, so existing notes on disk already read as `BestEffort` and
89/// no data migration is needed. Orthogonal to [`NoteKind`] — a rule or a fact
90/// may be either policy. This is the SINGLE reader; never match the tag
91/// inline at call sites.
92pub fn injection_policy(manifest: &crate::skill::SkillManifest) -> Option<InjectionPolicy> {
93    if manifest.category != crate::skill::Category::Note {
94        return None;
95    }
96    if manifest.tags.iter().any(|t| t == REQUIRED_TAG) {
97        Some(InjectionPolicy::Required)
98    } else {
99        Some(InjectionPolicy::BestEffort)
100    }
101}
102
103/// The tag that marks a note as a permanent instruction.
104///
105/// Private on purpose: [`injection_policy`] is the single reader and
106/// [`set_injection_policy`] the single writer, so no call site ever matches
107/// this string inline.
108const REQUIRED_TAG: &str = "required";
109
110/// Set a note's injection policy, in place.
111///
112/// The single WRITER of the policy tag, paired with [`injection_policy`] as
113/// the single reader. It exists because `NoteSpec` deliberately has no policy
114/// field: Required must come only from an explicit user action at one of the
115/// two `/memories` creation entries (plan invariant 3), so the note builders
116/// shared by the runtime, the `remember` tool, and `mur notes create` cannot
117/// express it at all. Promotion and demotion are the same operation with a
118/// different argument.
119///
120/// No-op for a non-note manifest: injection policy is a note concept, and
121/// silently tagging a `Category::Skill` would invent a guarantee the injector
122/// does not honour.
123pub fn set_injection_policy(manifest: &mut crate::skill::SkillManifest, policy: InjectionPolicy) {
124    if manifest.category != crate::skill::Category::Note {
125        return;
126    }
127    let tagged = manifest.tags.iter().any(|t| t == REQUIRED_TAG);
128    match policy {
129        // Idempotent: promoting twice must not leave two `required` tags, or
130        // a later demotion would only remove one and the note would stay
131        // Required while the UI said otherwise.
132        InjectionPolicy::Required if !tagged => manifest.tags.push(REQUIRED_TAG.into()),
133        InjectionPolicy::Required => {}
134        // Demotion strips EVERY copy, for the same reason.
135        InjectionPolicy::BestEffort => manifest.tags.retain(|t| t != REQUIRED_TAG),
136    }
137}
138
139/// Decay half-life multiplier for `manifest` under thresholds `t` — notes get
140/// per-kind curves; everything else (and a missing manifest) is 1.0.
141pub fn half_life_factor_for(
142    manifest: Option<&crate::skill::SkillManifest>,
143    t: &LifecycleThresholds,
144) -> f64 {
145    match manifest.and_then(note_kind) {
146        Some(NoteKind::Rule) => t.note_rule_half_life_factor,
147        Some(NoteKind::Fact) => t.note_fact_half_life_factor,
148        None => 1.0,
149    }
150}
151
152/// Runtime-immutable lifecycle thresholds, derived from `SkillLifecycleConfig`.
153///
154/// Created once per sweep and threaded through to `next_state`. The `Default`
155/// impl mirrors the compile-time constants below so callers that don't have
156/// access to config (e.g. the doctor's read-only preview) continue to work
157/// without any config file.
158#[derive(Debug, Clone)]
159pub struct LifecycleThresholds {
160    pub promote_draft_uses: u64,
161    pub promote_emerging_uses: u64,
162    pub promote_emerging_success_rate: f64,
163    pub promote_emerging_age_days: i64,
164    pub promote_stable_uses: u64,
165    pub promote_stable_success_rate: f64,
166    pub promote_stable_age_days: i64,
167    pub demote_emerging_uses: u64,
168    pub demote_emerging_success_rate: f64,
169    pub demote_stable_uses: u64,
170    pub demote_stable_success_rate: f64,
171    pub deprecated_success_rate: f64,
172    pub deprecated_no_success_days: i64,
173    pub auto_archive_confidence: f64,
174    pub auto_archive_age_days: i64,
175    /// Per-kind decay multipliers (federation P1). See [`half_life_factor_for`].
176    pub note_rule_half_life_factor: f64,
177    pub note_fact_half_life_factor: f64,
178}
179
180impl Default for LifecycleThresholds {
181    fn default() -> Self {
182        Self {
183            promote_draft_uses: PROMOTE_DRAFT_USES,
184            promote_emerging_uses: PROMOTE_EMERGING_USES,
185            promote_emerging_success_rate: PROMOTE_EMERGING_SUCCESS_RATE,
186            promote_emerging_age_days: PROMOTE_EMERGING_AGE_DAYS,
187            promote_stable_uses: PROMOTE_STABLE_USES,
188            promote_stable_success_rate: PROMOTE_STABLE_SUCCESS_RATE,
189            promote_stable_age_days: PROMOTE_STABLE_AGE_DAYS,
190            demote_emerging_uses: DEMOTE_EMERGING_USES,
191            demote_emerging_success_rate: DEMOTE_EMERGING_SUCCESS_RATE,
192            demote_stable_uses: DEMOTE_STABLE_USES,
193            demote_stable_success_rate: DEMOTE_STABLE_SUCCESS_RATE,
194            deprecated_success_rate: DEPRECATED_SUCCESS_RATE,
195            deprecated_no_success_days: DEPRECATED_NO_SUCCESS_DAYS,
196            auto_archive_confidence: AUTO_ARCHIVE_CONFIDENCE,
197            auto_archive_age_days: AUTO_ARCHIVE_AGE_DAYS,
198            note_rule_half_life_factor: NOTE_RULE_HALF_LIFE_FACTOR,
199            note_fact_half_life_factor: NOTE_FACT_HALF_LIFE_FACTOR,
200        }
201    }
202}
203
204impl From<&SkillLifecycleConfig> for LifecycleThresholds {
205    fn from(c: &SkillLifecycleConfig) -> Self {
206        Self {
207            promote_draft_uses: c.promote_draft_uses,
208            promote_emerging_uses: c.promote_emerging_uses,
209            promote_emerging_success_rate: c.promote_emerging_success_rate,
210            promote_emerging_age_days: c.promote_emerging_age_days,
211            promote_stable_uses: c.promote_stable_uses,
212            promote_stable_success_rate: c.promote_stable_success_rate,
213            promote_stable_age_days: c.promote_stable_age_days,
214            demote_emerging_uses: c.demote_emerging_uses,
215            demote_emerging_success_rate: c.demote_emerging_success_rate,
216            demote_stable_uses: c.demote_stable_uses,
217            demote_stable_success_rate: c.demote_stable_success_rate,
218            deprecated_success_rate: c.deprecated_success_rate,
219            deprecated_no_success_days: c.deprecated_no_success_days,
220            auto_archive_confidence: c.auto_archive_confidence,
221            auto_archive_age_days: c.auto_archive_age_days,
222            note_rule_half_life_factor: c.note_rule_half_life_factor,
223            note_fact_half_life_factor: c.note_fact_half_life_factor,
224        }
225    }
226}
227
228/// Promotion thresholds — values that MUST be exceeded.
229pub const PROMOTE_DRAFT_USES: u64 = 3;
230pub const PROMOTE_EMERGING_USES: u64 = 10;
231pub const PROMOTE_EMERGING_SUCCESS_RATE: f64 = 0.6;
232pub const PROMOTE_EMERGING_AGE_DAYS: i64 = 7;
233pub const PROMOTE_STABLE_USES: u64 = 30;
234pub const PROMOTE_STABLE_SUCCESS_RATE: f64 = 0.8;
235pub const PROMOTE_STABLE_AGE_DAYS: i64 = 30;
236
237/// Demotion thresholds — values that MUST drop BELOW. Hysteresis: lower
238/// than the symmetric promotion threshold to prevent flap.
239pub const DEMOTE_EMERGING_USES: u64 = 8;
240pub const DEMOTE_EMERGING_SUCCESS_RATE: f64 = 0.55;
241pub const DEMOTE_STABLE_USES: u64 = 25;
242pub const DEMOTE_STABLE_SUCCESS_RATE: f64 = 0.75;
243pub const DEPRECATED_SUCCESS_RATE: f64 = 0.3;
244pub const DEPRECATED_NO_SUCCESS_DAYS: i64 = 90;
245
246/// Compute decayed confidence given an anchor, last success time, and
247/// the half-life for the current lifecycle state.
248pub fn calculate_decay(
249    anchor_confidence: f64,
250    last_success: Option<DateTime<Utc>>,
251    half_life_days: f64,
252    now: DateTime<Utc>,
253) -> f64 {
254    let conf = anchor_confidence.clamp(0.0, 1.0);
255    if !conf.is_finite() || half_life_days <= 0.0 {
256        return MIN_CONFIDENCE;
257    }
258    let last = match last_success {
259        None => return MIN_CONFIDENCE,
260        Some(t) => t.min(now), // clock-skew defence
261    };
262    let days = (now - last).num_seconds() as f64 / 86_400.0;
263    if days <= 0.0 {
264        return conf;
265    }
266    (conf * 0.5_f64.powf(days / half_life_days)).max(MIN_CONFIDENCE)
267}
268
269/// Compute what state the skill *should* be in given its current stats
270/// and the current time. PURE — does not mutate. Idempotent: calling
271/// this twice with the same inputs returns the same output.
272///
273/// Caller (M5b sweep, or M5a doctor preview) decides whether to
274/// persist or merely display the result.
275///
276/// Pass `&LifecycleThresholds::default()` when config is not available
277/// (e.g. doctor read-only preview).
278pub fn next_state(
279    stats: &SkillStats,
280    now: DateTime<Utc>,
281    t: &LifecycleThresholds,
282) -> LifecycleState {
283    let current = stats.lifecycle_state;
284
285    // Destroyed is terminal — the files are gone; the sweep never calls
286    // next_state for destroyed skills, but guard defensively.
287    if current == LifecycleState::Destroyed {
288        return LifecycleState::Destroyed;
289    }
290
291    // Hard archive condition (overrides everything except pinned).
292    if !stats.pinned {
293        let decayed = calculate_decay(
294            stats.anchor_confidence,
295            stats.last_success_at,
296            half_life_days(current),
297            now,
298        );
299        if let Some(first_ok) = stats.first_successful_use_at {
300            let age_days = (now - first_ok).num_days();
301            if decayed < t.auto_archive_confidence && age_days > t.auto_archive_age_days {
302                return LifecycleState::Archived;
303            }
304        }
305    }
306
307    let success_rate = if stats.usage_count == 0 {
308        0.0
309    } else {
310        stats.success_count as f64 / stats.usage_count as f64
311    };
312    let age_days = stats
313        .first_successful_use_at
314        .map(|t| (now - t).num_days())
315        .unwrap_or(0);
316    let no_success_days = stats
317        .last_success_at
318        .map(|ts| (now - ts).num_days())
319        .unwrap_or(i64::MAX);
320
321    // Deprecation predicate — applies from any non-Archived state.
322    if !stats.pinned
323        && current != LifecycleState::Archived
324        && (success_rate < t.deprecated_success_rate && stats.usage_count >= 5
325            || no_success_days > t.deprecated_no_success_days)
326    {
327        return LifecycleState::Deprecated;
328    }
329
330    // Promotion ladder. Each rung requires the prior rung's criteria.
331    let can_canonical = stats.pinned
332        && stats.success_count >= t.promote_stable_uses
333        && success_rate >= t.promote_stable_success_rate
334        && age_days >= t.promote_stable_age_days;
335    let can_stable = stats.success_count >= t.promote_emerging_uses
336        && success_rate >= t.promote_emerging_success_rate
337        && age_days >= t.promote_emerging_age_days;
338    let can_emerging = stats.success_count >= t.promote_draft_uses;
339
340    if can_canonical {
341        LifecycleState::Canonical
342    } else if can_stable {
343        LifecycleState::Stable
344    } else if can_emerging {
345        LifecycleState::Emerging
346    } else {
347        LifecycleState::Draft
348    }
349}
350
351/// Cap a proposed lifecycle state for LLM-authored, uncurated skills.
352///
353/// PURE. The promotion ladder (`next_state`) is provenance-blind; this
354/// applies the A1 curation gate on top: an `Llm` skill that no human has
355/// curated cannot rise above `Emerging`, no matter how good its run stats
356/// look. `Human`/`Hybrid` skills, curated skills, and a disabled gate all
357/// pass `proposed` through unchanged. States at or below `Emerging` are
358/// never raised.
359/// Whether decay may demote this item.
360///
361/// Decay arrived on 2026-02-25 as "Pattern Maturity + Automatic Decay" — the
362/// filter that made *automatic mining* survivable, because most of what a miner
363/// produces is noise. The pattern pipeline was removed in #404 and notes
364/// inherited the machinery, but not the condition it depended on.
365///
366/// The axis is not human-versus-machine, which conflates MUR's own shipped
367/// builtins with what you wrote: deprecating a builtin you never use is
368/// correct, and `mur sync` puts it back. The axis is **replaceability**. Decay
369/// ends at `Archived`, `Archived` ends at `remove_dir_all`, and that is
370/// survivable only for content MUR can reinstall.
371///
372/// So: machine proposals decay wherever they came from, MUR-published content
373/// decays because it is recoverable, and everything else — a `mur notes create`
374/// note, an agent memory, a skill you authored — does not.
375///
376/// Demotion only. Evidence of actual failure (the broken-workflow fast path)
377/// still demotes anything, because that is a measurement rather than a guess
378/// about staleness.
379pub fn decay_may_demote(publisher: &str, provenance: Provenance, curated: bool) -> bool {
380    // A machine proposal is noise until something proves otherwise, whoever
381    // published it.
382    if provenance == Provenance::Llm && !curated {
383        return true;
384    }
385    crate::skill::types::is_mur_owned_publisher(publisher)
386}
387
388pub fn cap_for_provenance(
389    proposed: LifecycleState,
390    provenance: Provenance,
391    curated: bool,
392    gate_enabled: bool,
393) -> LifecycleState {
394    let gated = gate_enabled && provenance == Provenance::Llm && !curated;
395    if gated && lifecycle_rank(proposed) > lifecycle_rank(LifecycleState::Emerging) {
396        LifecycleState::Emerging
397    } else {
398        proposed
399    }
400}
401
402/// Returns true if the transition from `from` to `to` may be persisted
403/// *right now*. Even when `next_state` says a transition is warranted,
404/// this guard prevents:
405///   - flap within MIN_DWELL_HOURS of the last transition
406///   - downward transitions for pinned skills below their pinned tier
407///   - hysteresis bounce around exact thresholds
408pub fn transition_allowed(
409    from: LifecycleState,
410    to: LifecycleState,
411    stats: &SkillStats,
412    now: DateTime<Utc>,
413) -> bool {
414    if from == to {
415        return false;
416    }
417    if stats.pinned && lifecycle_rank(to) < lifecycle_rank(from) {
418        return false;
419    }
420    let elapsed = now - stats.lifecycle_changed_at;
421    if elapsed < Duration::hours(MIN_DWELL_HOURS) {
422        return false;
423    }
424    true
425}
426
427/// Total order over lifecycle states. Public because the federation snapshot
428/// floor (mur-core) compares against the same ranking — a duplicated table
429/// drifting from this one would silently change what federates.
430pub fn lifecycle_rank(s: LifecycleState) -> u8 {
431    match s {
432        LifecycleState::Destroyed => 0,
433        LifecycleState::Archived => 1,
434        LifecycleState::Deprecated => 2,
435        LifecycleState::Draft => 3,
436        LifecycleState::Emerging => 4,
437        LifecycleState::Stable => 5,
438        LifecycleState::Canonical => 6,
439    }
440}
441
442/// Called by the M5b sweep AFTER persisting a promotion. Resets the
443/// confidence anchor so the new half-life applies from current, not
444/// stale, confidence. Without this, a skill promoted from Draft to
445/// Emerging would carry its already-decayed anchor under the longer
446/// Emerging half-life and appear artificially fresh forever.
447///
448/// M5b's sweep MUST call this after writing the new `lifecycle_state`
449/// to disk. M5a never calls it.
450pub fn on_promotion(stats: &mut SkillStats, now: DateTime<Utc>) {
451    let prior_half_life = half_life_days(stats.lifecycle_state);
452    let decayed = calculate_decay(
453        stats.anchor_confidence,
454        stats.last_success_at,
455        prior_half_life,
456        now,
457    );
458    stats.anchor_confidence = decayed;
459    stats.lifecycle_changed_at = now;
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use chrono::TimeZone;
466
467    fn make_stats(
468        state: LifecycleState,
469        usage: u64,
470        success: u64,
471        first_ok_days_ago: i64,
472        last_ok_days_ago: i64,
473        anchor: f64,
474        pinned: bool,
475    ) -> SkillStats {
476        let now = Utc::now();
477        SkillStats {
478            schema_version: 1,
479            skill_name: "test".into(),
480            skill_version: "1.0.0".into(),
481            manifest_digest: "abc".into(),
482            lifecycle_state: state,
483            lifecycle_changed_at: now - Duration::hours(48),
484            pinned,
485            pinned_reason: String::new(),
486            usage_count: usage,
487            success_count: success,
488            failure_count: usage.saturating_sub(success),
489            last_used_at: Some(now - Duration::days(last_ok_days_ago)),
490            last_success_at: Some(now - Duration::days(last_ok_days_ago)),
491            first_successful_use_at: Some(now - Duration::days(first_ok_days_ago)),
492            anchor_confidence: anchor,
493            rebuilt_from_trace_through: None,
494            resolution_misses: 0,
495            curated_at: None,
496        }
497    }
498
499    #[test]
500    fn decay_floor_honored_at_extreme_age() {
501        let now = Utc::now();
502        let last = Some(now - Duration::days(10_000));
503        let conf = calculate_decay(1.0, last, 14.0, now);
504        assert_eq!(conf, MIN_CONFIDENCE);
505    }
506
507    #[test]
508    fn clock_skew_clamped_returns_anchor_unchanged() {
509        let now = Utc::now();
510        let future = now + Duration::days(1);
511        let conf = calculate_decay(0.8, Some(future), 14.0, now);
512        assert_eq!(conf, 0.8);
513    }
514
515    #[test]
516    fn decay_no_last_success_returns_min() {
517        let now = Utc::now();
518        let conf = calculate_decay(1.0, None, 14.0, now);
519        assert_eq!(conf, MIN_CONFIDENCE);
520    }
521
522    #[test]
523    fn next_state_idempotent() {
524        let now = Utc::now();
525        let stats = make_stats(LifecycleState::Draft, 1, 1, 1, 0, 1.0, false);
526        let s1 = next_state(&stats, now, &LifecycleThresholds::default());
527        let s2 = next_state(&stats, now, &LifecycleThresholds::default());
528        assert_eq!(s1, s2);
529    }
530
531    #[test]
532    fn promotion_full_ladder() {
533        let now = Utc::now();
534        // Enough successes, age, and rate to reach Canonical (with pin)
535        let stats = make_stats(LifecycleState::Draft, 50, 45, 40, 0, 1.0, true);
536        assert_eq!(
537            next_state(&stats, now, &LifecycleThresholds::default()),
538            LifecycleState::Canonical
539        );
540    }
541
542    #[test]
543    fn emerging_without_pin() {
544        let now = Utc::now();
545        let stats = make_stats(LifecycleState::Draft, 5, 4, 10, 1, 1.0, false);
546        // 5 successes ≥ PROMOTE_DRAFT_USES=3, but not enough age for Stable
547        assert_eq!(
548            next_state(&stats, now, &LifecycleThresholds::default()),
549            LifecycleState::Emerging
550        );
551    }
552
553    #[test]
554    fn deprecation_from_low_success_rate() {
555        let now = Utc::now();
556        let stats = make_stats(LifecycleState::Emerging, 10, 2, 30, 10, 0.5, false);
557        // success_rate = 0.2 < 0.3, usage >= 5
558        assert_eq!(
559            next_state(&stats, now, &LifecycleThresholds::default()),
560            LifecycleState::Deprecated
561        );
562    }
563
564    #[test]
565    fn pinned_floor_prevents_demotion() {
566        let now_fixed = Utc.with_ymd_and_hms(2026, 5, 25, 0, 0, 0).unwrap();
567        // Bad metrics would normally demote, but pinned
568        let stats = SkillStats {
569            lifecycle_state: LifecycleState::Stable,
570            pinned: true,
571            usage_count: 10,
572            success_count: 2,
573            failure_count: 8,
574            anchor_confidence: 0.5,
575            last_success_at: Some(now_fixed - Duration::days(120)),
576            first_successful_use_at: Some(now_fixed),
577            lifecycle_changed_at: now_fixed - Duration::hours(48),
578            ..make_stats(LifecycleState::Stable, 10, 2, 30, 120, 0.5, true)
579        };
580        // Pinned: should not deprecate despite terrible metrics
581        let state = next_state(&stats, now_fixed, &LifecycleThresholds::default());
582        assert_ne!(state, LifecycleState::Deprecated);
583    }
584
585    #[test]
586    fn transition_allowed_dwell_within_24h_returns_false() {
587        let now = Utc::now();
588        let stats = SkillStats {
589            lifecycle_changed_at: now - Duration::hours(1),
590            pinned: false,
591            ..make_stats(LifecycleState::Draft, 0, 0, 0, 0, 1.0, false)
592        };
593        assert!(!transition_allowed(
594            LifecycleState::Draft,
595            LifecycleState::Emerging,
596            &stats,
597            now,
598        ));
599    }
600
601    #[test]
602    fn transition_allowed_identical_from_to_returns_false() {
603        let now = Utc::now();
604        let stats = make_stats(LifecycleState::Draft, 0, 0, 0, 0, 1.0, false);
605        assert!(!transition_allowed(
606            LifecycleState::Draft,
607            LifecycleState::Draft,
608            &stats,
609            now,
610        ));
611    }
612
613    #[test]
614    fn transition_allowed_downgrade_pinned_blocked() {
615        let now = Utc::now();
616        let stats = SkillStats {
617            lifecycle_changed_at: now - Duration::hours(48),
618            pinned: true,
619            ..make_stats(LifecycleState::Stable, 0, 0, 0, 0, 1.0, true)
620        };
621        assert!(!transition_allowed(
622            LifecycleState::Stable,
623            LifecycleState::Emerging,
624            &stats,
625            now,
626        ));
627    }
628
629    #[test]
630    fn on_promotion_resets_anchor() {
631        let now = Utc::now();
632        let mut stats = make_stats(LifecycleState::Draft, 0, 0, 0, 0, 1.0, false);
633        let old_anchor = stats.anchor_confidence;
634        on_promotion(&mut stats, now);
635        // Anchor should be recalculated; lifecycle_changed_at updated
636        assert!(stats.lifecycle_changed_at >= now - Duration::seconds(1));
637        // Decayed value from a 1.0 anchor with 0 successes and no last_success
638        // → MIN_CONFIDENCE since last_success is None
639        assert!(stats.anchor_confidence <= old_anchor);
640    }
641
642    #[test]
643    fn cap_blocks_llm_uncurated_above_emerging() {
644        // Stable proposed, LLM, not curated, gate on → capped to Emerging.
645        assert_eq!(
646            cap_for_provenance(LifecycleState::Stable, Provenance::Llm, false, true),
647            LifecycleState::Emerging
648        );
649        // Canonical likewise capped.
650        assert_eq!(
651            cap_for_provenance(LifecycleState::Canonical, Provenance::Llm, false, true),
652            LifecycleState::Emerging
653        );
654    }
655
656    #[test]
657    fn cap_is_noop_for_human_curated_or_disabled() {
658        // Human authorship → never gated.
659        assert_eq!(
660            cap_for_provenance(LifecycleState::Stable, Provenance::Human, false, true),
661            LifecycleState::Stable
662        );
663        // LLM but curated → gate open.
664        assert_eq!(
665            cap_for_provenance(LifecycleState::Stable, Provenance::Llm, true, true),
666            LifecycleState::Stable
667        );
668        // Gate disabled by config → no cap.
669        assert_eq!(
670            cap_for_provenance(LifecycleState::Canonical, Provenance::Llm, false, false),
671            LifecycleState::Canonical
672        );
673        // At or below Emerging → unchanged even when gated.
674        assert_eq!(
675            cap_for_provenance(LifecycleState::Draft, Provenance::Llm, false, true),
676            LifecycleState::Draft
677        );
678    }
679}
680
681#[cfg(test)]
682mod note_kind_tests {
683    use super::*;
684
685    fn manifest(category: &str, tags: &str) -> crate::skill::SkillManifest {
686        crate::skill::parse_canonical(&format!(
687            "name: t\nversion: 1.0.0\npublisher: human:t\ndescription: d\ncategory: {category}\ntags: {tags}\ncontent:\n  abstract: a\n  context: c\n"
688        ))
689        .unwrap()
690    }
691
692    #[test]
693    fn note_kind_rule_fact_and_none() {
694        assert_eq!(note_kind(&manifest("note", "[rule]")), Some(NoteKind::Rule));
695        assert_eq!(note_kind(&manifest("note", "[]")), Some(NoteKind::Fact));
696        assert_eq!(note_kind(&manifest("context", "[rule]")), None);
697    }
698
699    #[test]
700    fn injection_policy_required_tag_best_effort_default_and_none() {
701        // An explicit `required` tag is the ONLY way a note becomes Required
702        // (invariant 3: never by migration, classifier, or score).
703        assert_eq!(
704            injection_policy(&manifest("note", "[required]")),
705            Some(InjectionPolicy::Required)
706        );
707        // A plain note — and every pre-existing note on disk — reads as
708        // BestEffort, which is what makes §10's migration a no-op.
709        assert_eq!(
710            injection_policy(&manifest("note", "[]")),
711            Some(InjectionPolicy::BestEffort)
712        );
713        // Policy is orthogonal to kind: a rule can be a permanent instruction.
714        assert_eq!(
715            injection_policy(&manifest("note", "[rule, required]")),
716            Some(InjectionPolicy::Required)
717        );
718        // Non-note skills have no injection policy at all.
719        assert_eq!(injection_policy(&manifest("context", "[required]")), None);
720    }
721
722    /// The writer must round-trip through the reader, and must be idempotent
723    /// in both directions.
724    ///
725    /// Double-promotion leaving two `required` tags is the specific bug this
726    /// pins: a later demotion that stripped only one copy would leave the note
727    /// injected every turn while `/memories` listed it as BestEffort — a
728    /// silent, invisible permanent instruction.
729    #[test]
730    fn set_injection_policy_round_trips_and_is_idempotent() {
731        let mut m = manifest("note", "[rule]");
732        assert_eq!(injection_policy(&m), Some(InjectionPolicy::BestEffort));
733
734        set_injection_policy(&mut m, InjectionPolicy::Required);
735        assert_eq!(injection_policy(&m), Some(InjectionPolicy::Required));
736        // Kind survives a policy change — the two are orthogonal.
737        assert_eq!(note_kind(&m), Some(NoteKind::Rule));
738
739        set_injection_policy(&mut m, InjectionPolicy::Required);
740        assert_eq!(
741            m.tags.iter().filter(|t| *t == "required").count(),
742            1,
743            "promoting twice must not duplicate the tag: {:?}",
744            m.tags
745        );
746
747        set_injection_policy(&mut m, InjectionPolicy::BestEffort);
748        assert_eq!(injection_policy(&m), Some(InjectionPolicy::BestEffort));
749        assert_eq!(note_kind(&m), Some(NoteKind::Rule));
750        set_injection_policy(&mut m, InjectionPolicy::BestEffort);
751        assert_eq!(injection_policy(&m), Some(InjectionPolicy::BestEffort));
752    }
753
754    /// Even a hand-corrupted manifest carrying duplicate tags must demote
755    /// cleanly — `retain` strips every copy, so recovery is always possible.
756    #[test]
757    fn demotion_strips_duplicate_required_tags() {
758        let mut m = manifest("note", "[required, rule, required]");
759        set_injection_policy(&mut m, InjectionPolicy::BestEffort);
760        assert_eq!(injection_policy(&m), Some(InjectionPolicy::BestEffort));
761    }
762
763    /// A non-note manifest must not be given a policy it cannot honour.
764    #[test]
765    fn set_injection_policy_ignores_non_notes() {
766        let mut m = manifest("context", "[]");
767        set_injection_policy(&mut m, InjectionPolicy::Required);
768        assert!(
769            !m.tags.iter().any(|t| t == "required"),
770            "a non-note must not be tagged Required: {:?}",
771            m.tags
772        );
773        assert_eq!(injection_policy(&m), None);
774    }
775
776    #[test]
777    fn half_life_factor_rule_halves_fact_doubles_skill_unchanged() {
778        let t = LifecycleThresholds::default();
779        assert!(half_life_factor_for(Some(&manifest("note", "[rule]")), &t) < 1.0);
780        assert!(half_life_factor_for(Some(&manifest("note", "[]")), &t) > 1.0);
781        assert_eq!(half_life_factor_for(None, &t), 1.0);
782        assert_eq!(
783            half_life_factor_for(Some(&manifest("context", "[]")), &t),
784            1.0
785        );
786    }
787}