1use chrono::{DateTime, Duration, Utc};
6
7use crate::skill::stats::{LifecycleState, SkillStats};
8use crate::skill::types::Provenance;
9
10pub const MIN_CONFIDENCE: f64 = 0.05;
11pub const AUTO_ARCHIVE_CONFIDENCE: f64 = 0.10;
12pub const AUTO_ARCHIVE_AGE_DAYS: i64 = 180;
13pub const MIN_DWELL_HOURS: i64 = 24;
14
15pub fn half_life_days(state: LifecycleState) -> f64 {
17 match state {
18 LifecycleState::Draft => 14.0,
19 LifecycleState::Emerging => 90.0,
20 LifecycleState::Stable => 365.0,
21 LifecycleState::Canonical => 730.0,
22 LifecycleState::Deprecated | LifecycleState::Archived => 365.0,
23 }
24}
25
26pub const PROMOTE_DRAFT_USES: u64 = 3;
28pub const PROMOTE_EMERGING_USES: u64 = 10;
29pub const PROMOTE_EMERGING_SUCCESS_RATE: f64 = 0.6;
30pub const PROMOTE_EMERGING_AGE_DAYS: i64 = 7;
31pub const PROMOTE_STABLE_USES: u64 = 30;
32pub const PROMOTE_STABLE_SUCCESS_RATE: f64 = 0.8;
33pub const PROMOTE_STABLE_AGE_DAYS: i64 = 30;
34
35pub const DEMOTE_EMERGING_USES: u64 = 8;
38pub const DEMOTE_EMERGING_SUCCESS_RATE: f64 = 0.55;
39pub const DEMOTE_STABLE_USES: u64 = 25;
40pub const DEMOTE_STABLE_SUCCESS_RATE: f64 = 0.75;
41pub const DEPRECATED_SUCCESS_RATE: f64 = 0.3;
42pub const DEPRECATED_NO_SUCCESS_DAYS: i64 = 90;
43
44pub fn calculate_decay(
47 anchor_confidence: f64,
48 last_success: Option<DateTime<Utc>>,
49 half_life_days: f64,
50 now: DateTime<Utc>,
51) -> f64 {
52 let conf = anchor_confidence.clamp(0.0, 1.0);
53 if !conf.is_finite() || half_life_days <= 0.0 {
54 return MIN_CONFIDENCE;
55 }
56 let last = match last_success {
57 None => return MIN_CONFIDENCE,
58 Some(t) => t.min(now), };
60 let days = (now - last).num_seconds() as f64 / 86_400.0;
61 if days <= 0.0 {
62 return conf;
63 }
64 (conf * 0.5_f64.powf(days / half_life_days)).max(MIN_CONFIDENCE)
65}
66
67pub fn next_state(stats: &SkillStats, now: DateTime<Utc>) -> LifecycleState {
74 let current = stats.lifecycle_state;
75
76 if !stats.pinned {
78 let decayed = calculate_decay(
79 stats.anchor_confidence,
80 stats.last_success_at,
81 half_life_days(current),
82 now,
83 );
84 if let Some(first_ok) = stats.first_successful_use_at {
85 let age_days = (now - first_ok).num_days();
86 if decayed < AUTO_ARCHIVE_CONFIDENCE && age_days > AUTO_ARCHIVE_AGE_DAYS {
87 return LifecycleState::Archived;
88 }
89 }
90 }
91
92 let success_rate = if stats.usage_count == 0 {
93 0.0
94 } else {
95 stats.success_count as f64 / stats.usage_count as f64
96 };
97 let age_days = stats
98 .first_successful_use_at
99 .map(|t| (now - t).num_days())
100 .unwrap_or(0);
101 let no_success_days = stats
102 .last_success_at
103 .map(|t| (now - t).num_days())
104 .unwrap_or(i64::MAX);
105
106 if !stats.pinned
108 && current != LifecycleState::Archived
109 && (success_rate < DEPRECATED_SUCCESS_RATE && stats.usage_count >= 5
110 || no_success_days > DEPRECATED_NO_SUCCESS_DAYS)
111 {
112 return LifecycleState::Deprecated;
113 }
114
115 let can_canonical = stats.pinned
117 && stats.success_count >= PROMOTE_STABLE_USES
118 && success_rate >= PROMOTE_STABLE_SUCCESS_RATE
119 && age_days >= PROMOTE_STABLE_AGE_DAYS;
120 let can_stable = stats.success_count >= PROMOTE_EMERGING_USES
121 && success_rate >= PROMOTE_EMERGING_SUCCESS_RATE
122 && age_days >= PROMOTE_EMERGING_AGE_DAYS;
123 let can_emerging = stats.success_count >= PROMOTE_DRAFT_USES;
124
125 if can_canonical {
126 LifecycleState::Canonical
127 } else if can_stable {
128 LifecycleState::Stable
129 } else if can_emerging {
130 LifecycleState::Emerging
131 } else {
132 LifecycleState::Draft
133 }
134}
135
136pub fn cap_for_provenance(
145 proposed: LifecycleState,
146 provenance: Provenance,
147 curated: bool,
148 gate_enabled: bool,
149) -> LifecycleState {
150 let gated = gate_enabled && provenance == Provenance::Llm && !curated;
151 if gated && rank(proposed) > rank(LifecycleState::Emerging) {
152 LifecycleState::Emerging
153 } else {
154 proposed
155 }
156}
157
158pub fn transition_allowed(
165 from: LifecycleState,
166 to: LifecycleState,
167 stats: &SkillStats,
168 now: DateTime<Utc>,
169) -> bool {
170 if from == to {
171 return false;
172 }
173 if stats.pinned && rank(to) < rank(from) {
174 return false;
175 }
176 let elapsed = now - stats.lifecycle_changed_at;
177 if elapsed < Duration::hours(MIN_DWELL_HOURS) {
178 return false;
179 }
180 true
181}
182
183fn rank(s: LifecycleState) -> u8 {
184 match s {
185 LifecycleState::Archived => 0,
186 LifecycleState::Deprecated => 1,
187 LifecycleState::Draft => 2,
188 LifecycleState::Emerging => 3,
189 LifecycleState::Stable => 4,
190 LifecycleState::Canonical => 5,
191 }
192}
193
194pub fn on_promotion(stats: &mut SkillStats, now: DateTime<Utc>) {
203 let prior_half_life = half_life_days(stats.lifecycle_state);
204 let decayed = calculate_decay(
205 stats.anchor_confidence,
206 stats.last_success_at,
207 prior_half_life,
208 now,
209 );
210 stats.anchor_confidence = decayed;
211 stats.lifecycle_changed_at = now;
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use chrono::TimeZone;
218
219 fn make_stats(
220 state: LifecycleState,
221 usage: u64,
222 success: u64,
223 first_ok_days_ago: i64,
224 last_ok_days_ago: i64,
225 anchor: f64,
226 pinned: bool,
227 ) -> SkillStats {
228 let now = Utc::now();
229 SkillStats {
230 schema_version: 1,
231 skill_name: "test".into(),
232 skill_version: "1.0.0".into(),
233 manifest_digest: "abc".into(),
234 lifecycle_state: state,
235 lifecycle_changed_at: now - Duration::hours(48),
236 pinned,
237 pinned_reason: String::new(),
238 usage_count: usage,
239 success_count: success,
240 failure_count: usage.saturating_sub(success),
241 last_used_at: Some(now - Duration::days(last_ok_days_ago)),
242 last_success_at: Some(now - Duration::days(last_ok_days_ago)),
243 first_successful_use_at: Some(now - Duration::days(first_ok_days_ago)),
244 anchor_confidence: anchor,
245 rebuilt_from_trace_through: None,
246 resolution_misses: 0,
247 curated_at: None,
248 }
249 }
250
251 #[test]
252 fn decay_floor_honored_at_extreme_age() {
253 let now = Utc::now();
254 let last = Some(now - Duration::days(10_000));
255 let conf = calculate_decay(1.0, last, 14.0, now);
256 assert_eq!(conf, MIN_CONFIDENCE);
257 }
258
259 #[test]
260 fn clock_skew_clamped_returns_anchor_unchanged() {
261 let now = Utc::now();
262 let future = now + Duration::days(1);
263 let conf = calculate_decay(0.8, Some(future), 14.0, now);
264 assert_eq!(conf, 0.8);
265 }
266
267 #[test]
268 fn decay_no_last_success_returns_min() {
269 let now = Utc::now();
270 let conf = calculate_decay(1.0, None, 14.0, now);
271 assert_eq!(conf, MIN_CONFIDENCE);
272 }
273
274 #[test]
275 fn next_state_idempotent() {
276 let now = Utc::now();
277 let stats = make_stats(LifecycleState::Draft, 1, 1, 1, 0, 1.0, false);
278 let s1 = next_state(&stats, now);
279 let s2 = next_state(&stats, now);
280 assert_eq!(s1, s2);
281 }
282
283 #[test]
284 fn promotion_full_ladder() {
285 let now = Utc::now();
286 let stats = make_stats(LifecycleState::Draft, 50, 45, 40, 0, 1.0, true);
288 assert_eq!(next_state(&stats, now), LifecycleState::Canonical);
289 }
290
291 #[test]
292 fn emerging_without_pin() {
293 let now = Utc::now();
294 let stats = make_stats(LifecycleState::Draft, 5, 4, 10, 1, 1.0, false);
295 assert_eq!(next_state(&stats, now), LifecycleState::Emerging);
297 }
298
299 #[test]
300 fn deprecation_from_low_success_rate() {
301 let now = Utc::now();
302 let stats = make_stats(LifecycleState::Emerging, 10, 2, 30, 10, 0.5, false);
303 assert_eq!(next_state(&stats, now), LifecycleState::Deprecated);
305 }
306
307 #[test]
308 fn pinned_floor_prevents_demotion() {
309 let now_fixed = Utc.with_ymd_and_hms(2026, 5, 25, 0, 0, 0).unwrap();
310 let stats = SkillStats {
312 lifecycle_state: LifecycleState::Stable,
313 pinned: true,
314 usage_count: 10,
315 success_count: 2,
316 failure_count: 8,
317 anchor_confidence: 0.5,
318 last_success_at: Some(now_fixed - Duration::days(120)),
319 first_successful_use_at: Some(now_fixed),
320 lifecycle_changed_at: now_fixed - Duration::hours(48),
321 ..make_stats(LifecycleState::Stable, 10, 2, 30, 120, 0.5, true)
322 };
323 let state = next_state(&stats, now_fixed);
325 assert_ne!(state, LifecycleState::Deprecated);
326 }
327
328 #[test]
329 fn transition_allowed_dwell_within_24h_returns_false() {
330 let now = Utc::now();
331 let stats = SkillStats {
332 lifecycle_changed_at: now - Duration::hours(1),
333 pinned: false,
334 ..make_stats(LifecycleState::Draft, 0, 0, 0, 0, 1.0, false)
335 };
336 assert!(!transition_allowed(
337 LifecycleState::Draft,
338 LifecycleState::Emerging,
339 &stats,
340 now,
341 ));
342 }
343
344 #[test]
345 fn transition_allowed_identical_from_to_returns_false() {
346 let now = Utc::now();
347 let stats = make_stats(LifecycleState::Draft, 0, 0, 0, 0, 1.0, false);
348 assert!(!transition_allowed(
349 LifecycleState::Draft,
350 LifecycleState::Draft,
351 &stats,
352 now,
353 ));
354 }
355
356 #[test]
357 fn transition_allowed_downgrade_pinned_blocked() {
358 let now = Utc::now();
359 let stats = SkillStats {
360 lifecycle_changed_at: now - Duration::hours(48),
361 pinned: true,
362 ..make_stats(LifecycleState::Stable, 0, 0, 0, 0, 1.0, true)
363 };
364 assert!(!transition_allowed(
365 LifecycleState::Stable,
366 LifecycleState::Emerging,
367 &stats,
368 now,
369 ));
370 }
371
372 #[test]
373 fn on_promotion_resets_anchor() {
374 let now = Utc::now();
375 let mut stats = make_stats(LifecycleState::Draft, 0, 0, 0, 0, 1.0, false);
376 let old_anchor = stats.anchor_confidence;
377 on_promotion(&mut stats, now);
378 assert!(stats.lifecycle_changed_at >= now - Duration::seconds(1));
380 assert!(stats.anchor_confidence <= old_anchor);
383 }
384
385 #[test]
386 fn cap_blocks_llm_uncurated_above_emerging() {
387 assert_eq!(
389 cap_for_provenance(LifecycleState::Stable, Provenance::Llm, false, true),
390 LifecycleState::Emerging
391 );
392 assert_eq!(
394 cap_for_provenance(LifecycleState::Canonical, Provenance::Llm, false, true),
395 LifecycleState::Emerging
396 );
397 }
398
399 #[test]
400 fn cap_is_noop_for_human_curated_or_disabled() {
401 assert_eq!(
403 cap_for_provenance(LifecycleState::Stable, Provenance::Human, false, true),
404 LifecycleState::Stable
405 );
406 assert_eq!(
408 cap_for_provenance(LifecycleState::Stable, Provenance::Llm, true, true),
409 LifecycleState::Stable
410 );
411 assert_eq!(
413 cap_for_provenance(LifecycleState::Canonical, Provenance::Llm, false, false),
414 LifecycleState::Canonical
415 );
416 assert_eq!(
418 cap_for_provenance(LifecycleState::Draft, Provenance::Llm, false, true),
419 LifecycleState::Draft
420 );
421 }
422}