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 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
306pub 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
331pub 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
346pub 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 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 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 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 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 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 assert!(stats.lifecycle_changed_at >= now - Duration::seconds(1));
541 assert!(stats.anchor_confidence <= old_anchor);
544 }
545
546 #[test]
547 fn cap_blocks_llm_uncurated_above_emerging() {
548 assert_eq!(
550 cap_for_provenance(LifecycleState::Stable, Provenance::Llm, false, true),
551 LifecycleState::Emerging
552 );
553 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 assert_eq!(
564 cap_for_provenance(LifecycleState::Stable, Provenance::Human, false, true),
565 LifecycleState::Stable
566 );
567 assert_eq!(
569 cap_for_provenance(LifecycleState::Stable, Provenance::Llm, true, true),
570 LifecycleState::Stable
571 );
572 assert_eq!(
574 cap_for_provenance(LifecycleState::Canonical, Provenance::Llm, false, false),
575 LifecycleState::Canonical
576 );
577 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}