1use chrono::{DateTime, Duration, Utc};
6
7use crate::skill::stats::{LifecycleState, SkillStats};
8
9pub const MIN_CONFIDENCE: f64 = 0.05;
10pub const AUTO_ARCHIVE_CONFIDENCE: f64 = 0.10;
11pub const AUTO_ARCHIVE_AGE_DAYS: i64 = 180;
12pub const MIN_DWELL_HOURS: i64 = 24;
13
14pub fn half_life_days(state: LifecycleState) -> f64 {
16 match state {
17 LifecycleState::Draft => 14.0,
18 LifecycleState::Emerging => 90.0,
19 LifecycleState::Stable => 365.0,
20 LifecycleState::Canonical => 730.0,
21 LifecycleState::Deprecated | LifecycleState::Archived => 365.0,
22 }
23}
24
25pub const PROMOTE_DRAFT_USES: u64 = 3;
27pub const PROMOTE_EMERGING_USES: u64 = 10;
28pub const PROMOTE_EMERGING_SUCCESS_RATE: f64 = 0.6;
29pub const PROMOTE_EMERGING_AGE_DAYS: i64 = 7;
30pub const PROMOTE_STABLE_USES: u64 = 30;
31pub const PROMOTE_STABLE_SUCCESS_RATE: f64 = 0.8;
32pub const PROMOTE_STABLE_AGE_DAYS: i64 = 30;
33
34pub const DEMOTE_EMERGING_USES: u64 = 8;
37pub const DEMOTE_EMERGING_SUCCESS_RATE: f64 = 0.55;
38pub const DEMOTE_STABLE_USES: u64 = 25;
39pub const DEMOTE_STABLE_SUCCESS_RATE: f64 = 0.75;
40pub const DEPRECATED_SUCCESS_RATE: f64 = 0.3;
41pub const DEPRECATED_NO_SUCCESS_DAYS: i64 = 90;
42
43pub fn calculate_decay(
46 anchor_confidence: f64,
47 last_success: Option<DateTime<Utc>>,
48 half_life_days: f64,
49 now: DateTime<Utc>,
50) -> f64 {
51 let conf = anchor_confidence.clamp(0.0, 1.0);
52 if !conf.is_finite() || half_life_days <= 0.0 {
53 return MIN_CONFIDENCE;
54 }
55 let last = match last_success {
56 None => return MIN_CONFIDENCE,
57 Some(t) => t.min(now), };
59 let days = (now - last).num_seconds() as f64 / 86_400.0;
60 if days <= 0.0 {
61 return conf;
62 }
63 (conf * 0.5_f64.powf(days / half_life_days)).max(MIN_CONFIDENCE)
64}
65
66pub fn next_state(stats: &SkillStats, now: DateTime<Utc>) -> LifecycleState {
73 let current = stats.lifecycle_state;
74
75 if !stats.pinned {
77 let decayed = calculate_decay(
78 stats.anchor_confidence,
79 stats.last_success_at,
80 half_life_days(current),
81 now,
82 );
83 if let Some(first_ok) = stats.first_successful_use_at {
84 let age_days = (now - first_ok).num_days();
85 if decayed < AUTO_ARCHIVE_CONFIDENCE && age_days > AUTO_ARCHIVE_AGE_DAYS {
86 return LifecycleState::Archived;
87 }
88 }
89 }
90
91 let success_rate = if stats.usage_count == 0 {
92 0.0
93 } else {
94 stats.success_count as f64 / stats.usage_count as f64
95 };
96 let age_days = stats
97 .first_successful_use_at
98 .map(|t| (now - t).num_days())
99 .unwrap_or(0);
100 let no_success_days = stats
101 .last_success_at
102 .map(|t| (now - t).num_days())
103 .unwrap_or(i64::MAX);
104
105 if !stats.pinned
107 && current != LifecycleState::Archived
108 && (success_rate < DEPRECATED_SUCCESS_RATE && stats.usage_count >= 5
109 || no_success_days > DEPRECATED_NO_SUCCESS_DAYS)
110 {
111 return LifecycleState::Deprecated;
112 }
113
114 let can_canonical = stats.pinned
116 && stats.success_count >= PROMOTE_STABLE_USES
117 && success_rate >= PROMOTE_STABLE_SUCCESS_RATE
118 && age_days >= PROMOTE_STABLE_AGE_DAYS;
119 let can_stable = stats.success_count >= PROMOTE_EMERGING_USES
120 && success_rate >= PROMOTE_EMERGING_SUCCESS_RATE
121 && age_days >= PROMOTE_EMERGING_AGE_DAYS;
122 let can_emerging = stats.success_count >= PROMOTE_DRAFT_USES;
123
124 if can_canonical {
125 LifecycleState::Canonical
126 } else if can_stable {
127 LifecycleState::Stable
128 } else if can_emerging {
129 LifecycleState::Emerging
130 } else {
131 LifecycleState::Draft
132 }
133}
134
135pub fn transition_allowed(
142 from: LifecycleState,
143 to: LifecycleState,
144 stats: &SkillStats,
145 now: DateTime<Utc>,
146) -> bool {
147 if from == to {
148 return false;
149 }
150 if stats.pinned && rank(to) < rank(from) {
151 return false;
152 }
153 let elapsed = now - stats.lifecycle_changed_at;
154 if elapsed < Duration::hours(MIN_DWELL_HOURS) {
155 return false;
156 }
157 true
158}
159
160fn rank(s: LifecycleState) -> u8 {
161 match s {
162 LifecycleState::Archived => 0,
163 LifecycleState::Deprecated => 1,
164 LifecycleState::Draft => 2,
165 LifecycleState::Emerging => 3,
166 LifecycleState::Stable => 4,
167 LifecycleState::Canonical => 5,
168 }
169}
170
171pub fn on_promotion(stats: &mut SkillStats, now: DateTime<Utc>) {
180 let prior_half_life = half_life_days(stats.lifecycle_state);
181 let decayed = calculate_decay(
182 stats.anchor_confidence,
183 stats.last_success_at,
184 prior_half_life,
185 now,
186 );
187 stats.anchor_confidence = decayed;
188 stats.lifecycle_changed_at = now;
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use chrono::TimeZone;
195
196 fn make_stats(
197 state: LifecycleState,
198 usage: u64,
199 success: u64,
200 first_ok_days_ago: i64,
201 last_ok_days_ago: i64,
202 anchor: f64,
203 pinned: bool,
204 ) -> SkillStats {
205 let now = Utc::now();
206 SkillStats {
207 schema_version: 1,
208 skill_name: "test".into(),
209 skill_version: "1.0.0".into(),
210 manifest_digest: "abc".into(),
211 lifecycle_state: state,
212 lifecycle_changed_at: now - Duration::hours(48),
213 pinned,
214 pinned_reason: String::new(),
215 usage_count: usage,
216 success_count: success,
217 failure_count: usage.saturating_sub(success),
218 last_used_at: Some(now - Duration::days(last_ok_days_ago)),
219 last_success_at: Some(now - Duration::days(last_ok_days_ago)),
220 first_successful_use_at: Some(now - Duration::days(first_ok_days_ago)),
221 anchor_confidence: anchor,
222 rebuilt_from_trace_through: None,
223 resolution_misses: 0,
224 }
225 }
226
227 #[test]
228 fn decay_floor_honored_at_extreme_age() {
229 let now = Utc::now();
230 let last = Some(now - Duration::days(10_000));
231 let conf = calculate_decay(1.0, last, 14.0, now);
232 assert_eq!(conf, MIN_CONFIDENCE);
233 }
234
235 #[test]
236 fn clock_skew_clamped_returns_anchor_unchanged() {
237 let now = Utc::now();
238 let future = now + Duration::days(1);
239 let conf = calculate_decay(0.8, Some(future), 14.0, now);
240 assert_eq!(conf, 0.8);
241 }
242
243 #[test]
244 fn decay_no_last_success_returns_min() {
245 let now = Utc::now();
246 let conf = calculate_decay(1.0, None, 14.0, now);
247 assert_eq!(conf, MIN_CONFIDENCE);
248 }
249
250 #[test]
251 fn next_state_idempotent() {
252 let now = Utc::now();
253 let stats = make_stats(LifecycleState::Draft, 1, 1, 1, 0, 1.0, false);
254 let s1 = next_state(&stats, now);
255 let s2 = next_state(&stats, now);
256 assert_eq!(s1, s2);
257 }
258
259 #[test]
260 fn promotion_full_ladder() {
261 let now = Utc::now();
262 let stats = make_stats(LifecycleState::Draft, 50, 45, 40, 0, 1.0, true);
264 assert_eq!(next_state(&stats, now), LifecycleState::Canonical);
265 }
266
267 #[test]
268 fn emerging_without_pin() {
269 let now = Utc::now();
270 let stats = make_stats(LifecycleState::Draft, 5, 4, 10, 1, 1.0, false);
271 assert_eq!(next_state(&stats, now), LifecycleState::Emerging);
273 }
274
275 #[test]
276 fn deprecation_from_low_success_rate() {
277 let now = Utc::now();
278 let stats = make_stats(LifecycleState::Emerging, 10, 2, 30, 10, 0.5, false);
279 assert_eq!(next_state(&stats, now), LifecycleState::Deprecated);
281 }
282
283 #[test]
284 fn pinned_floor_prevents_demotion() {
285 let now_fixed = Utc.with_ymd_and_hms(2026, 5, 25, 0, 0, 0).unwrap();
286 let stats = SkillStats {
288 lifecycle_state: LifecycleState::Stable,
289 pinned: true,
290 usage_count: 10,
291 success_count: 2,
292 failure_count: 8,
293 anchor_confidence: 0.5,
294 last_success_at: Some(now_fixed - Duration::days(120)),
295 first_successful_use_at: Some(now_fixed),
296 lifecycle_changed_at: now_fixed - Duration::hours(48),
297 ..make_stats(LifecycleState::Stable, 10, 2, 30, 120, 0.5, true)
298 };
299 let state = next_state(&stats, now_fixed);
301 assert_ne!(state, LifecycleState::Deprecated);
302 }
303
304 #[test]
305 fn transition_allowed_dwell_within_24h_returns_false() {
306 let now = Utc::now();
307 let stats = SkillStats {
308 lifecycle_changed_at: now - Duration::hours(1),
309 pinned: false,
310 ..make_stats(LifecycleState::Draft, 0, 0, 0, 0, 1.0, false)
311 };
312 assert!(!transition_allowed(
313 LifecycleState::Draft,
314 LifecycleState::Emerging,
315 &stats,
316 now,
317 ));
318 }
319
320 #[test]
321 fn transition_allowed_identical_from_to_returns_false() {
322 let now = Utc::now();
323 let stats = make_stats(LifecycleState::Draft, 0, 0, 0, 0, 1.0, false);
324 assert!(!transition_allowed(
325 LifecycleState::Draft,
326 LifecycleState::Draft,
327 &stats,
328 now,
329 ));
330 }
331
332 #[test]
333 fn transition_allowed_downgrade_pinned_blocked() {
334 let now = Utc::now();
335 let stats = SkillStats {
336 lifecycle_changed_at: now - Duration::hours(48),
337 pinned: true,
338 ..make_stats(LifecycleState::Stable, 0, 0, 0, 0, 1.0, true)
339 };
340 assert!(!transition_allowed(
341 LifecycleState::Stable,
342 LifecycleState::Emerging,
343 &stats,
344 now,
345 ));
346 }
347
348 #[test]
349 fn on_promotion_resets_anchor() {
350 let now = Utc::now();
351 let mut stats = make_stats(LifecycleState::Draft, 0, 0, 0, 0, 1.0, false);
352 let old_anchor = stats.anchor_confidence;
353 on_promotion(&mut stats, now);
354 assert!(stats.lifecycle_changed_at >= now - Duration::seconds(1));
356 assert!(stats.anchor_confidence <= old_anchor);
359 }
360}