1use 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
16pub 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
27pub const NOTE_RULE_HALF_LIFE_FACTOR: f64 = 0.5;
33pub const NOTE_FACT_HALF_LIFE_FACTOR: f64 = 2.0;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum NoteKind {
39 Rule,
41 Fact,
43}
44
45impl NoteKind {
46 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
58pub 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
72pub 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#[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 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
161pub 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
170pub 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
179pub 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), };
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
202pub fn next_state(
212 stats: &SkillStats,
213 now: DateTime<Utc>,
214 t: &LifecycleThresholds,
215) -> LifecycleState {
216 let current = stats.lifecycle_state;
217
218 if current == LifecycleState::Destroyed {
221 return LifecycleState::Destroyed;
222 }
223
224 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 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 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
284pub fn decay_may_demote(publisher: &str, provenance: Provenance, curated: bool) -> bool {
313 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
335pub 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
360pub 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
375pub 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 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 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 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 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 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 assert!(stats.lifecycle_changed_at >= now - Duration::seconds(1));
570 assert!(stats.anchor_confidence <= old_anchor);
573 }
574
575 #[test]
576 fn cap_blocks_llm_uncurated_above_emerging() {
577 assert_eq!(
579 cap_for_provenance(LifecycleState::Stable, Provenance::Llm, false, true),
580 LifecycleState::Emerging
581 );
582 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 assert_eq!(
593 cap_for_provenance(LifecycleState::Stable, Provenance::Human, false, true),
594 LifecycleState::Stable
595 );
596 assert_eq!(
598 cap_for_provenance(LifecycleState::Stable, Provenance::Llm, true, true),
599 LifecycleState::Stable
600 );
601 assert_eq!(
603 cap_for_provenance(LifecycleState::Canonical, Provenance::Llm, false, false),
604 LifecycleState::Canonical
605 );
606 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}