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
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum InjectionPolicy {
78 Required,
80 BestEffort,
82}
83
84pub 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
103const REQUIRED_TAG: &str = "required";
109
110pub 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 InjectionPolicy::Required if !tagged => manifest.tags.push(REQUIRED_TAG.into()),
133 InjectionPolicy::Required => {}
134 InjectionPolicy::BestEffort => manifest.tags.retain(|t| t != REQUIRED_TAG),
136 }
137}
138
139pub 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#[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 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
228pub 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
237pub 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
246pub 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), };
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
269pub fn next_state(
279 stats: &SkillStats,
280 now: DateTime<Utc>,
281 t: &LifecycleThresholds,
282) -> LifecycleState {
283 let current = stats.lifecycle_state;
284
285 if current == LifecycleState::Destroyed {
288 return LifecycleState::Destroyed;
289 }
290
291 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 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 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
351pub fn decay_may_demote(publisher: &str, provenance: Provenance, curated: bool) -> bool {
380 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
402pub 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
427pub 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
442pub 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 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 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 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 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 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 assert!(stats.lifecycle_changed_at >= now - Duration::seconds(1));
637 assert!(stats.anchor_confidence <= old_anchor);
640 }
641
642 #[test]
643 fn cap_blocks_llm_uncurated_above_emerging() {
644 assert_eq!(
646 cap_for_provenance(LifecycleState::Stable, Provenance::Llm, false, true),
647 LifecycleState::Emerging
648 );
649 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 assert_eq!(
660 cap_for_provenance(LifecycleState::Stable, Provenance::Human, false, true),
661 LifecycleState::Stable
662 );
663 assert_eq!(
665 cap_for_provenance(LifecycleState::Stable, Provenance::Llm, true, true),
666 LifecycleState::Stable
667 );
668 assert_eq!(
670 cap_for_provenance(LifecycleState::Canonical, Provenance::Llm, false, false),
671 LifecycleState::Canonical
672 );
673 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 assert_eq!(
704 injection_policy(&manifest("note", "[required]")),
705 Some(InjectionPolicy::Required)
706 );
707 assert_eq!(
710 injection_policy(&manifest("note", "[]")),
711 Some(InjectionPolicy::BestEffort)
712 );
713 assert_eq!(
715 injection_policy(&manifest("note", "[rule, required]")),
716 Some(InjectionPolicy::Required)
717 );
718 assert_eq!(injection_policy(&manifest("context", "[required]")), None);
720 }
721
722 #[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 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 #[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 #[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}