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.
292pub fn cap_for_provenance(
293    proposed: LifecycleState,
294    provenance: Provenance,
295    curated: bool,
296    gate_enabled: bool,
297) -> LifecycleState {
298    let gated = gate_enabled && provenance == Provenance::Llm && !curated;
299    if gated && lifecycle_rank(proposed) > lifecycle_rank(LifecycleState::Emerging) {
300        LifecycleState::Emerging
301    } else {
302        proposed
303    }
304}
305
306/// Returns true if the transition from `from` to `to` may be persisted
307/// *right now*. Even when `next_state` says a transition is warranted,
308/// this guard prevents:
309///   - flap within MIN_DWELL_HOURS of the last transition
310///   - downward transitions for pinned skills below their pinned tier
311///   - hysteresis bounce around exact thresholds
312pub fn transition_allowed(
313    from: LifecycleState,
314    to: LifecycleState,
315    stats: &SkillStats,
316    now: DateTime<Utc>,
317) -> bool {
318    if from == to {
319        return false;
320    }
321    if stats.pinned && lifecycle_rank(to) < lifecycle_rank(from) {
322        return false;
323    }
324    let elapsed = now - stats.lifecycle_changed_at;
325    if elapsed < Duration::hours(MIN_DWELL_HOURS) {
326        return false;
327    }
328    true
329}
330
331/// Total order over lifecycle states. Public because the federation snapshot
332/// floor (mur-core) compares against the same ranking — a duplicated table
333/// drifting from this one would silently change what federates.
334pub fn lifecycle_rank(s: LifecycleState) -> u8 {
335    match s {
336        LifecycleState::Destroyed => 0,
337        LifecycleState::Archived => 1,
338        LifecycleState::Deprecated => 2,
339        LifecycleState::Draft => 3,
340        LifecycleState::Emerging => 4,
341        LifecycleState::Stable => 5,
342        LifecycleState::Canonical => 6,
343    }
344}
345
346/// Called by the M5b sweep AFTER persisting a promotion. Resets the
347/// confidence anchor so the new half-life applies from current, not
348/// stale, confidence. Without this, a skill promoted from Draft to
349/// Emerging would carry its already-decayed anchor under the longer
350/// Emerging half-life and appear artificially fresh forever.
351///
352/// M5b's sweep MUST call this after writing the new `lifecycle_state`
353/// to disk. M5a never calls it.
354pub fn on_promotion(stats: &mut SkillStats, now: DateTime<Utc>) {
355    let prior_half_life = half_life_days(stats.lifecycle_state);
356    let decayed = calculate_decay(
357        stats.anchor_confidence,
358        stats.last_success_at,
359        prior_half_life,
360        now,
361    );
362    stats.anchor_confidence = decayed;
363    stats.lifecycle_changed_at = now;
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use chrono::TimeZone;
370
371    fn make_stats(
372        state: LifecycleState,
373        usage: u64,
374        success: u64,
375        first_ok_days_ago: i64,
376        last_ok_days_ago: i64,
377        anchor: f64,
378        pinned: bool,
379    ) -> SkillStats {
380        let now = Utc::now();
381        SkillStats {
382            schema_version: 1,
383            skill_name: "test".into(),
384            skill_version: "1.0.0".into(),
385            manifest_digest: "abc".into(),
386            lifecycle_state: state,
387            lifecycle_changed_at: now - Duration::hours(48),
388            pinned,
389            pinned_reason: String::new(),
390            usage_count: usage,
391            success_count: success,
392            failure_count: usage.saturating_sub(success),
393            last_used_at: Some(now - Duration::days(last_ok_days_ago)),
394            last_success_at: Some(now - Duration::days(last_ok_days_ago)),
395            first_successful_use_at: Some(now - Duration::days(first_ok_days_ago)),
396            anchor_confidence: anchor,
397            rebuilt_from_trace_through: None,
398            resolution_misses: 0,
399            curated_at: None,
400        }
401    }
402
403    #[test]
404    fn decay_floor_honored_at_extreme_age() {
405        let now = Utc::now();
406        let last = Some(now - Duration::days(10_000));
407        let conf = calculate_decay(1.0, last, 14.0, now);
408        assert_eq!(conf, MIN_CONFIDENCE);
409    }
410
411    #[test]
412    fn clock_skew_clamped_returns_anchor_unchanged() {
413        let now = Utc::now();
414        let future = now + Duration::days(1);
415        let conf = calculate_decay(0.8, Some(future), 14.0, now);
416        assert_eq!(conf, 0.8);
417    }
418
419    #[test]
420    fn decay_no_last_success_returns_min() {
421        let now = Utc::now();
422        let conf = calculate_decay(1.0, None, 14.0, now);
423        assert_eq!(conf, MIN_CONFIDENCE);
424    }
425
426    #[test]
427    fn next_state_idempotent() {
428        let now = Utc::now();
429        let stats = make_stats(LifecycleState::Draft, 1, 1, 1, 0, 1.0, false);
430        let s1 = next_state(&stats, now, &LifecycleThresholds::default());
431        let s2 = next_state(&stats, now, &LifecycleThresholds::default());
432        assert_eq!(s1, s2);
433    }
434
435    #[test]
436    fn promotion_full_ladder() {
437        let now = Utc::now();
438        // Enough successes, age, and rate to reach Canonical (with pin)
439        let stats = make_stats(LifecycleState::Draft, 50, 45, 40, 0, 1.0, true);
440        assert_eq!(
441            next_state(&stats, now, &LifecycleThresholds::default()),
442            LifecycleState::Canonical
443        );
444    }
445
446    #[test]
447    fn emerging_without_pin() {
448        let now = Utc::now();
449        let stats = make_stats(LifecycleState::Draft, 5, 4, 10, 1, 1.0, false);
450        // 5 successes ≥ PROMOTE_DRAFT_USES=3, but not enough age for Stable
451        assert_eq!(
452            next_state(&stats, now, &LifecycleThresholds::default()),
453            LifecycleState::Emerging
454        );
455    }
456
457    #[test]
458    fn deprecation_from_low_success_rate() {
459        let now = Utc::now();
460        let stats = make_stats(LifecycleState::Emerging, 10, 2, 30, 10, 0.5, false);
461        // success_rate = 0.2 < 0.3, usage >= 5
462        assert_eq!(
463            next_state(&stats, now, &LifecycleThresholds::default()),
464            LifecycleState::Deprecated
465        );
466    }
467
468    #[test]
469    fn pinned_floor_prevents_demotion() {
470        let now_fixed = Utc.with_ymd_and_hms(2026, 5, 25, 0, 0, 0).unwrap();
471        // Bad metrics would normally demote, but pinned
472        let stats = SkillStats {
473            lifecycle_state: LifecycleState::Stable,
474            pinned: true,
475            usage_count: 10,
476            success_count: 2,
477            failure_count: 8,
478            anchor_confidence: 0.5,
479            last_success_at: Some(now_fixed - Duration::days(120)),
480            first_successful_use_at: Some(now_fixed),
481            lifecycle_changed_at: now_fixed - Duration::hours(48),
482            ..make_stats(LifecycleState::Stable, 10, 2, 30, 120, 0.5, true)
483        };
484        // Pinned: should not deprecate despite terrible metrics
485        let state = next_state(&stats, now_fixed, &LifecycleThresholds::default());
486        assert_ne!(state, LifecycleState::Deprecated);
487    }
488
489    #[test]
490    fn transition_allowed_dwell_within_24h_returns_false() {
491        let now = Utc::now();
492        let stats = SkillStats {
493            lifecycle_changed_at: now - Duration::hours(1),
494            pinned: false,
495            ..make_stats(LifecycleState::Draft, 0, 0, 0, 0, 1.0, false)
496        };
497        assert!(!transition_allowed(
498            LifecycleState::Draft,
499            LifecycleState::Emerging,
500            &stats,
501            now,
502        ));
503    }
504
505    #[test]
506    fn transition_allowed_identical_from_to_returns_false() {
507        let now = Utc::now();
508        let stats = make_stats(LifecycleState::Draft, 0, 0, 0, 0, 1.0, false);
509        assert!(!transition_allowed(
510            LifecycleState::Draft,
511            LifecycleState::Draft,
512            &stats,
513            now,
514        ));
515    }
516
517    #[test]
518    fn transition_allowed_downgrade_pinned_blocked() {
519        let now = Utc::now();
520        let stats = SkillStats {
521            lifecycle_changed_at: now - Duration::hours(48),
522            pinned: true,
523            ..make_stats(LifecycleState::Stable, 0, 0, 0, 0, 1.0, true)
524        };
525        assert!(!transition_allowed(
526            LifecycleState::Stable,
527            LifecycleState::Emerging,
528            &stats,
529            now,
530        ));
531    }
532
533    #[test]
534    fn on_promotion_resets_anchor() {
535        let now = Utc::now();
536        let mut stats = make_stats(LifecycleState::Draft, 0, 0, 0, 0, 1.0, false);
537        let old_anchor = stats.anchor_confidence;
538        on_promotion(&mut stats, now);
539        // Anchor should be recalculated; lifecycle_changed_at updated
540        assert!(stats.lifecycle_changed_at >= now - Duration::seconds(1));
541        // Decayed value from a 1.0 anchor with 0 successes and no last_success
542        // → MIN_CONFIDENCE since last_success is None
543        assert!(stats.anchor_confidence <= old_anchor);
544    }
545
546    #[test]
547    fn cap_blocks_llm_uncurated_above_emerging() {
548        // Stable proposed, LLM, not curated, gate on → capped to Emerging.
549        assert_eq!(
550            cap_for_provenance(LifecycleState::Stable, Provenance::Llm, false, true),
551            LifecycleState::Emerging
552        );
553        // Canonical likewise capped.
554        assert_eq!(
555            cap_for_provenance(LifecycleState::Canonical, Provenance::Llm, false, true),
556            LifecycleState::Emerging
557        );
558    }
559
560    #[test]
561    fn cap_is_noop_for_human_curated_or_disabled() {
562        // Human authorship → never gated.
563        assert_eq!(
564            cap_for_provenance(LifecycleState::Stable, Provenance::Human, false, true),
565            LifecycleState::Stable
566        );
567        // LLM but curated → gate open.
568        assert_eq!(
569            cap_for_provenance(LifecycleState::Stable, Provenance::Llm, true, true),
570            LifecycleState::Stable
571        );
572        // Gate disabled by config → no cap.
573        assert_eq!(
574            cap_for_provenance(LifecycleState::Canonical, Provenance::Llm, false, false),
575            LifecycleState::Canonical
576        );
577        // At or below Emerging → unchanged even when gated.
578        assert_eq!(
579            cap_for_provenance(LifecycleState::Draft, Provenance::Llm, false, true),
580            LifecycleState::Draft
581        );
582    }
583}
584
585#[cfg(test)]
586mod note_kind_tests {
587    use super::*;
588
589    fn manifest(category: &str, tags: &str) -> crate::skill::SkillManifest {
590        crate::skill::parse_canonical(&format!(
591            "name: t\nversion: 1.0.0\npublisher: human:t\ndescription: d\ncategory: {category}\ntags: {tags}\ncontent:\n  abstract: a\n  context: c\n"
592        ))
593        .unwrap()
594    }
595
596    #[test]
597    fn note_kind_rule_fact_and_none() {
598        assert_eq!(note_kind(&manifest("note", "[rule]")), Some(NoteKind::Rule));
599        assert_eq!(note_kind(&manifest("note", "[]")), Some(NoteKind::Fact));
600        assert_eq!(note_kind(&manifest("context", "[rule]")), None);
601    }
602
603    #[test]
604    fn half_life_factor_rule_halves_fact_doubles_skill_unchanged() {
605        let t = LifecycleThresholds::default();
606        assert!(half_life_factor_for(Some(&manifest("note", "[rule]")), &t) < 1.0);
607        assert!(half_life_factor_for(Some(&manifest("note", "[]")), &t) > 1.0);
608        assert_eq!(half_life_factor_for(None, &t), 1.0);
609        assert_eq!(
610            half_life_factor_for(Some(&manifest("context", "[]")), &t),
611            1.0
612        );
613    }
614}