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
27#[derive(Debug, Clone)]
34pub struct LifecycleThresholds {
35 pub promote_draft_uses: u64,
36 pub promote_emerging_uses: u64,
37 pub promote_emerging_success_rate: f64,
38 pub promote_emerging_age_days: i64,
39 pub promote_stable_uses: u64,
40 pub promote_stable_success_rate: f64,
41 pub promote_stable_age_days: i64,
42 pub demote_emerging_uses: u64,
43 pub demote_emerging_success_rate: f64,
44 pub demote_stable_uses: u64,
45 pub demote_stable_success_rate: f64,
46 pub deprecated_success_rate: f64,
47 pub deprecated_no_success_days: i64,
48 pub auto_archive_confidence: f64,
49 pub auto_archive_age_days: i64,
50}
51
52impl Default for LifecycleThresholds {
53 fn default() -> Self {
54 Self {
55 promote_draft_uses: PROMOTE_DRAFT_USES,
56 promote_emerging_uses: PROMOTE_EMERGING_USES,
57 promote_emerging_success_rate: PROMOTE_EMERGING_SUCCESS_RATE,
58 promote_emerging_age_days: PROMOTE_EMERGING_AGE_DAYS,
59 promote_stable_uses: PROMOTE_STABLE_USES,
60 promote_stable_success_rate: PROMOTE_STABLE_SUCCESS_RATE,
61 promote_stable_age_days: PROMOTE_STABLE_AGE_DAYS,
62 demote_emerging_uses: DEMOTE_EMERGING_USES,
63 demote_emerging_success_rate: DEMOTE_EMERGING_SUCCESS_RATE,
64 demote_stable_uses: DEMOTE_STABLE_USES,
65 demote_stable_success_rate: DEMOTE_STABLE_SUCCESS_RATE,
66 deprecated_success_rate: DEPRECATED_SUCCESS_RATE,
67 deprecated_no_success_days: DEPRECATED_NO_SUCCESS_DAYS,
68 auto_archive_confidence: AUTO_ARCHIVE_CONFIDENCE,
69 auto_archive_age_days: AUTO_ARCHIVE_AGE_DAYS,
70 }
71 }
72}
73
74impl From<&SkillLifecycleConfig> for LifecycleThresholds {
75 fn from(c: &SkillLifecycleConfig) -> Self {
76 Self {
77 promote_draft_uses: c.promote_draft_uses,
78 promote_emerging_uses: c.promote_emerging_uses,
79 promote_emerging_success_rate: c.promote_emerging_success_rate,
80 promote_emerging_age_days: c.promote_emerging_age_days,
81 promote_stable_uses: c.promote_stable_uses,
82 promote_stable_success_rate: c.promote_stable_success_rate,
83 promote_stable_age_days: c.promote_stable_age_days,
84 demote_emerging_uses: c.demote_emerging_uses,
85 demote_emerging_success_rate: c.demote_emerging_success_rate,
86 demote_stable_uses: c.demote_stable_uses,
87 demote_stable_success_rate: c.demote_stable_success_rate,
88 deprecated_success_rate: c.deprecated_success_rate,
89 deprecated_no_success_days: c.deprecated_no_success_days,
90 auto_archive_confidence: c.auto_archive_confidence,
91 auto_archive_age_days: c.auto_archive_age_days,
92 }
93 }
94}
95
96pub const PROMOTE_DRAFT_USES: u64 = 3;
98pub const PROMOTE_EMERGING_USES: u64 = 10;
99pub const PROMOTE_EMERGING_SUCCESS_RATE: f64 = 0.6;
100pub const PROMOTE_EMERGING_AGE_DAYS: i64 = 7;
101pub const PROMOTE_STABLE_USES: u64 = 30;
102pub const PROMOTE_STABLE_SUCCESS_RATE: f64 = 0.8;
103pub const PROMOTE_STABLE_AGE_DAYS: i64 = 30;
104
105pub const DEMOTE_EMERGING_USES: u64 = 8;
108pub const DEMOTE_EMERGING_SUCCESS_RATE: f64 = 0.55;
109pub const DEMOTE_STABLE_USES: u64 = 25;
110pub const DEMOTE_STABLE_SUCCESS_RATE: f64 = 0.75;
111pub const DEPRECATED_SUCCESS_RATE: f64 = 0.3;
112pub const DEPRECATED_NO_SUCCESS_DAYS: i64 = 90;
113
114pub fn calculate_decay(
117 anchor_confidence: f64,
118 last_success: Option<DateTime<Utc>>,
119 half_life_days: f64,
120 now: DateTime<Utc>,
121) -> f64 {
122 let conf = anchor_confidence.clamp(0.0, 1.0);
123 if !conf.is_finite() || half_life_days <= 0.0 {
124 return MIN_CONFIDENCE;
125 }
126 let last = match last_success {
127 None => return MIN_CONFIDENCE,
128 Some(t) => t.min(now), };
130 let days = (now - last).num_seconds() as f64 / 86_400.0;
131 if days <= 0.0 {
132 return conf;
133 }
134 (conf * 0.5_f64.powf(days / half_life_days)).max(MIN_CONFIDENCE)
135}
136
137pub fn next_state(
147 stats: &SkillStats,
148 now: DateTime<Utc>,
149 t: &LifecycleThresholds,
150) -> LifecycleState {
151 let current = stats.lifecycle_state;
152
153 if current == LifecycleState::Destroyed {
156 return LifecycleState::Destroyed;
157 }
158
159 if !stats.pinned {
161 let decayed = calculate_decay(
162 stats.anchor_confidence,
163 stats.last_success_at,
164 half_life_days(current),
165 now,
166 );
167 if let Some(first_ok) = stats.first_successful_use_at {
168 let age_days = (now - first_ok).num_days();
169 if decayed < t.auto_archive_confidence && age_days > t.auto_archive_age_days {
170 return LifecycleState::Archived;
171 }
172 }
173 }
174
175 let success_rate = if stats.usage_count == 0 {
176 0.0
177 } else {
178 stats.success_count as f64 / stats.usage_count as f64
179 };
180 let age_days = stats
181 .first_successful_use_at
182 .map(|t| (now - t).num_days())
183 .unwrap_or(0);
184 let no_success_days = stats
185 .last_success_at
186 .map(|ts| (now - ts).num_days())
187 .unwrap_or(i64::MAX);
188
189 if !stats.pinned
191 && current != LifecycleState::Archived
192 && (success_rate < t.deprecated_success_rate && stats.usage_count >= 5
193 || no_success_days > t.deprecated_no_success_days)
194 {
195 return LifecycleState::Deprecated;
196 }
197
198 let can_canonical = stats.pinned
200 && stats.success_count >= t.promote_stable_uses
201 && success_rate >= t.promote_stable_success_rate
202 && age_days >= t.promote_stable_age_days;
203 let can_stable = stats.success_count >= t.promote_emerging_uses
204 && success_rate >= t.promote_emerging_success_rate
205 && age_days >= t.promote_emerging_age_days;
206 let can_emerging = stats.success_count >= t.promote_draft_uses;
207
208 if can_canonical {
209 LifecycleState::Canonical
210 } else if can_stable {
211 LifecycleState::Stable
212 } else if can_emerging {
213 LifecycleState::Emerging
214 } else {
215 LifecycleState::Draft
216 }
217}
218
219pub fn cap_for_provenance(
228 proposed: LifecycleState,
229 provenance: Provenance,
230 curated: bool,
231 gate_enabled: bool,
232) -> LifecycleState {
233 let gated = gate_enabled && provenance == Provenance::Llm && !curated;
234 if gated && rank(proposed) > rank(LifecycleState::Emerging) {
235 LifecycleState::Emerging
236 } else {
237 proposed
238 }
239}
240
241pub fn transition_allowed(
248 from: LifecycleState,
249 to: LifecycleState,
250 stats: &SkillStats,
251 now: DateTime<Utc>,
252) -> bool {
253 if from == to {
254 return false;
255 }
256 if stats.pinned && rank(to) < rank(from) {
257 return false;
258 }
259 let elapsed = now - stats.lifecycle_changed_at;
260 if elapsed < Duration::hours(MIN_DWELL_HOURS) {
261 return false;
262 }
263 true
264}
265
266fn rank(s: LifecycleState) -> u8 {
267 match s {
268 LifecycleState::Destroyed => 0,
269 LifecycleState::Archived => 1,
270 LifecycleState::Deprecated => 2,
271 LifecycleState::Draft => 3,
272 LifecycleState::Emerging => 4,
273 LifecycleState::Stable => 5,
274 LifecycleState::Canonical => 6,
275 }
276}
277
278pub fn on_promotion(stats: &mut SkillStats, now: DateTime<Utc>) {
287 let prior_half_life = half_life_days(stats.lifecycle_state);
288 let decayed = calculate_decay(
289 stats.anchor_confidence,
290 stats.last_success_at,
291 prior_half_life,
292 now,
293 );
294 stats.anchor_confidence = decayed;
295 stats.lifecycle_changed_at = now;
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use chrono::TimeZone;
302
303 fn make_stats(
304 state: LifecycleState,
305 usage: u64,
306 success: u64,
307 first_ok_days_ago: i64,
308 last_ok_days_ago: i64,
309 anchor: f64,
310 pinned: bool,
311 ) -> SkillStats {
312 let now = Utc::now();
313 SkillStats {
314 schema_version: 1,
315 skill_name: "test".into(),
316 skill_version: "1.0.0".into(),
317 manifest_digest: "abc".into(),
318 lifecycle_state: state,
319 lifecycle_changed_at: now - Duration::hours(48),
320 pinned,
321 pinned_reason: String::new(),
322 usage_count: usage,
323 success_count: success,
324 failure_count: usage.saturating_sub(success),
325 last_used_at: Some(now - Duration::days(last_ok_days_ago)),
326 last_success_at: Some(now - Duration::days(last_ok_days_ago)),
327 first_successful_use_at: Some(now - Duration::days(first_ok_days_ago)),
328 anchor_confidence: anchor,
329 rebuilt_from_trace_through: None,
330 resolution_misses: 0,
331 curated_at: None,
332 }
333 }
334
335 #[test]
336 fn decay_floor_honored_at_extreme_age() {
337 let now = Utc::now();
338 let last = Some(now - Duration::days(10_000));
339 let conf = calculate_decay(1.0, last, 14.0, now);
340 assert_eq!(conf, MIN_CONFIDENCE);
341 }
342
343 #[test]
344 fn clock_skew_clamped_returns_anchor_unchanged() {
345 let now = Utc::now();
346 let future = now + Duration::days(1);
347 let conf = calculate_decay(0.8, Some(future), 14.0, now);
348 assert_eq!(conf, 0.8);
349 }
350
351 #[test]
352 fn decay_no_last_success_returns_min() {
353 let now = Utc::now();
354 let conf = calculate_decay(1.0, None, 14.0, now);
355 assert_eq!(conf, MIN_CONFIDENCE);
356 }
357
358 #[test]
359 fn next_state_idempotent() {
360 let now = Utc::now();
361 let stats = make_stats(LifecycleState::Draft, 1, 1, 1, 0, 1.0, false);
362 let s1 = next_state(&stats, now, &LifecycleThresholds::default());
363 let s2 = next_state(&stats, now, &LifecycleThresholds::default());
364 assert_eq!(s1, s2);
365 }
366
367 #[test]
368 fn promotion_full_ladder() {
369 let now = Utc::now();
370 let stats = make_stats(LifecycleState::Draft, 50, 45, 40, 0, 1.0, true);
372 assert_eq!(
373 next_state(&stats, now, &LifecycleThresholds::default()),
374 LifecycleState::Canonical
375 );
376 }
377
378 #[test]
379 fn emerging_without_pin() {
380 let now = Utc::now();
381 let stats = make_stats(LifecycleState::Draft, 5, 4, 10, 1, 1.0, false);
382 assert_eq!(
384 next_state(&stats, now, &LifecycleThresholds::default()),
385 LifecycleState::Emerging
386 );
387 }
388
389 #[test]
390 fn deprecation_from_low_success_rate() {
391 let now = Utc::now();
392 let stats = make_stats(LifecycleState::Emerging, 10, 2, 30, 10, 0.5, false);
393 assert_eq!(
395 next_state(&stats, now, &LifecycleThresholds::default()),
396 LifecycleState::Deprecated
397 );
398 }
399
400 #[test]
401 fn pinned_floor_prevents_demotion() {
402 let now_fixed = Utc.with_ymd_and_hms(2026, 5, 25, 0, 0, 0).unwrap();
403 let stats = SkillStats {
405 lifecycle_state: LifecycleState::Stable,
406 pinned: true,
407 usage_count: 10,
408 success_count: 2,
409 failure_count: 8,
410 anchor_confidence: 0.5,
411 last_success_at: Some(now_fixed - Duration::days(120)),
412 first_successful_use_at: Some(now_fixed),
413 lifecycle_changed_at: now_fixed - Duration::hours(48),
414 ..make_stats(LifecycleState::Stable, 10, 2, 30, 120, 0.5, true)
415 };
416 let state = next_state(&stats, now_fixed, &LifecycleThresholds::default());
418 assert_ne!(state, LifecycleState::Deprecated);
419 }
420
421 #[test]
422 fn transition_allowed_dwell_within_24h_returns_false() {
423 let now = Utc::now();
424 let stats = SkillStats {
425 lifecycle_changed_at: now - Duration::hours(1),
426 pinned: false,
427 ..make_stats(LifecycleState::Draft, 0, 0, 0, 0, 1.0, false)
428 };
429 assert!(!transition_allowed(
430 LifecycleState::Draft,
431 LifecycleState::Emerging,
432 &stats,
433 now,
434 ));
435 }
436
437 #[test]
438 fn transition_allowed_identical_from_to_returns_false() {
439 let now = Utc::now();
440 let stats = make_stats(LifecycleState::Draft, 0, 0, 0, 0, 1.0, false);
441 assert!(!transition_allowed(
442 LifecycleState::Draft,
443 LifecycleState::Draft,
444 &stats,
445 now,
446 ));
447 }
448
449 #[test]
450 fn transition_allowed_downgrade_pinned_blocked() {
451 let now = Utc::now();
452 let stats = SkillStats {
453 lifecycle_changed_at: now - Duration::hours(48),
454 pinned: true,
455 ..make_stats(LifecycleState::Stable, 0, 0, 0, 0, 1.0, true)
456 };
457 assert!(!transition_allowed(
458 LifecycleState::Stable,
459 LifecycleState::Emerging,
460 &stats,
461 now,
462 ));
463 }
464
465 #[test]
466 fn on_promotion_resets_anchor() {
467 let now = Utc::now();
468 let mut stats = make_stats(LifecycleState::Draft, 0, 0, 0, 0, 1.0, false);
469 let old_anchor = stats.anchor_confidence;
470 on_promotion(&mut stats, now);
471 assert!(stats.lifecycle_changed_at >= now - Duration::seconds(1));
473 assert!(stats.anchor_confidence <= old_anchor);
476 }
477
478 #[test]
479 fn cap_blocks_llm_uncurated_above_emerging() {
480 assert_eq!(
482 cap_for_provenance(LifecycleState::Stable, Provenance::Llm, false, true),
483 LifecycleState::Emerging
484 );
485 assert_eq!(
487 cap_for_provenance(LifecycleState::Canonical, Provenance::Llm, false, true),
488 LifecycleState::Emerging
489 );
490 }
491
492 #[test]
493 fn cap_is_noop_for_human_curated_or_disabled() {
494 assert_eq!(
496 cap_for_provenance(LifecycleState::Stable, Provenance::Human, false, true),
497 LifecycleState::Stable
498 );
499 assert_eq!(
501 cap_for_provenance(LifecycleState::Stable, Provenance::Llm, true, true),
502 LifecycleState::Stable
503 );
504 assert_eq!(
506 cap_for_provenance(LifecycleState::Canonical, Provenance::Llm, false, false),
507 LifecycleState::Canonical
508 );
509 assert_eq!(
511 cap_for_provenance(LifecycleState::Draft, Provenance::Llm, false, true),
512 LifecycleState::Draft
513 );
514 }
515}