use chrono::{DateTime, Duration, Utc};
use crate::skill::stats::{LifecycleState, SkillStats};
pub const MIN_CONFIDENCE: f64 = 0.05;
pub const AUTO_ARCHIVE_CONFIDENCE: f64 = 0.10;
pub const AUTO_ARCHIVE_AGE_DAYS: i64 = 180;
pub const MIN_DWELL_HOURS: i64 = 24;
pub fn half_life_days(state: LifecycleState) -> f64 {
match state {
LifecycleState::Draft => 14.0,
LifecycleState::Emerging => 90.0,
LifecycleState::Stable => 365.0,
LifecycleState::Canonical => 730.0,
LifecycleState::Deprecated | LifecycleState::Archived => 365.0,
}
}
pub const PROMOTE_DRAFT_USES: u64 = 3;
pub const PROMOTE_EMERGING_USES: u64 = 10;
pub const PROMOTE_EMERGING_SUCCESS_RATE: f64 = 0.6;
pub const PROMOTE_EMERGING_AGE_DAYS: i64 = 7;
pub const PROMOTE_STABLE_USES: u64 = 30;
pub const PROMOTE_STABLE_SUCCESS_RATE: f64 = 0.8;
pub const PROMOTE_STABLE_AGE_DAYS: i64 = 30;
pub const DEMOTE_EMERGING_USES: u64 = 8;
pub const DEMOTE_EMERGING_SUCCESS_RATE: f64 = 0.55;
pub const DEMOTE_STABLE_USES: u64 = 25;
pub const DEMOTE_STABLE_SUCCESS_RATE: f64 = 0.75;
pub const DEPRECATED_SUCCESS_RATE: f64 = 0.3;
pub const DEPRECATED_NO_SUCCESS_DAYS: i64 = 90;
pub fn calculate_decay(
anchor_confidence: f64,
last_success: Option<DateTime<Utc>>,
half_life_days: f64,
now: DateTime<Utc>,
) -> f64 {
let conf = anchor_confidence.clamp(0.0, 1.0);
if !conf.is_finite() || half_life_days <= 0.0 {
return MIN_CONFIDENCE;
}
let last = match last_success {
None => return MIN_CONFIDENCE,
Some(t) => t.min(now), };
let days = (now - last).num_seconds() as f64 / 86_400.0;
if days <= 0.0 {
return conf;
}
(conf * 0.5_f64.powf(days / half_life_days)).max(MIN_CONFIDENCE)
}
pub fn next_state(stats: &SkillStats, now: DateTime<Utc>) -> LifecycleState {
let current = stats.lifecycle_state;
if !stats.pinned {
let decayed = calculate_decay(
stats.anchor_confidence,
stats.last_success_at,
half_life_days(current),
now,
);
if let Some(first_ok) = stats.first_successful_use_at {
let age_days = (now - first_ok).num_days();
if decayed < AUTO_ARCHIVE_CONFIDENCE && age_days > AUTO_ARCHIVE_AGE_DAYS {
return LifecycleState::Archived;
}
}
}
let success_rate = if stats.usage_count == 0 {
0.0
} else {
stats.success_count as f64 / stats.usage_count as f64
};
let age_days = stats
.first_successful_use_at
.map(|t| (now - t).num_days())
.unwrap_or(0);
let no_success_days = stats
.last_success_at
.map(|t| (now - t).num_days())
.unwrap_or(i64::MAX);
if !stats.pinned
&& current != LifecycleState::Archived
&& (success_rate < DEPRECATED_SUCCESS_RATE && stats.usage_count >= 5
|| no_success_days > DEPRECATED_NO_SUCCESS_DAYS)
{
return LifecycleState::Deprecated;
}
let can_canonical = stats.pinned
&& stats.success_count >= PROMOTE_STABLE_USES
&& success_rate >= PROMOTE_STABLE_SUCCESS_RATE
&& age_days >= PROMOTE_STABLE_AGE_DAYS;
let can_stable = stats.success_count >= PROMOTE_EMERGING_USES
&& success_rate >= PROMOTE_EMERGING_SUCCESS_RATE
&& age_days >= PROMOTE_EMERGING_AGE_DAYS;
let can_emerging = stats.success_count >= PROMOTE_DRAFT_USES;
if can_canonical {
LifecycleState::Canonical
} else if can_stable {
LifecycleState::Stable
} else if can_emerging {
LifecycleState::Emerging
} else {
LifecycleState::Draft
}
}
pub fn transition_allowed(
from: LifecycleState,
to: LifecycleState,
stats: &SkillStats,
now: DateTime<Utc>,
) -> bool {
if from == to {
return false;
}
if stats.pinned && rank(to) < rank(from) {
return false;
}
let elapsed = now - stats.lifecycle_changed_at;
if elapsed < Duration::hours(MIN_DWELL_HOURS) {
return false;
}
true
}
fn rank(s: LifecycleState) -> u8 {
match s {
LifecycleState::Archived => 0,
LifecycleState::Deprecated => 1,
LifecycleState::Draft => 2,
LifecycleState::Emerging => 3,
LifecycleState::Stable => 4,
LifecycleState::Canonical => 5,
}
}
pub fn on_promotion(stats: &mut SkillStats, now: DateTime<Utc>) {
let prior_half_life = half_life_days(stats.lifecycle_state);
let decayed = calculate_decay(
stats.anchor_confidence,
stats.last_success_at,
prior_half_life,
now,
);
stats.anchor_confidence = decayed;
stats.lifecycle_changed_at = now;
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn make_stats(
state: LifecycleState,
usage: u64,
success: u64,
first_ok_days_ago: i64,
last_ok_days_ago: i64,
anchor: f64,
pinned: bool,
) -> SkillStats {
let now = Utc::now();
SkillStats {
schema_version: 1,
skill_name: "test".into(),
skill_version: "1.0.0".into(),
manifest_digest: "abc".into(),
lifecycle_state: state,
lifecycle_changed_at: now - Duration::hours(48),
pinned,
pinned_reason: String::new(),
usage_count: usage,
success_count: success,
failure_count: usage.saturating_sub(success),
last_used_at: Some(now - Duration::days(last_ok_days_ago)),
last_success_at: Some(now - Duration::days(last_ok_days_ago)),
first_successful_use_at: Some(now - Duration::days(first_ok_days_ago)),
anchor_confidence: anchor,
rebuilt_from_trace_through: None,
resolution_misses: 0,
}
}
#[test]
fn decay_floor_honored_at_extreme_age() {
let now = Utc::now();
let last = Some(now - Duration::days(10_000));
let conf = calculate_decay(1.0, last, 14.0, now);
assert_eq!(conf, MIN_CONFIDENCE);
}
#[test]
fn clock_skew_clamped_returns_anchor_unchanged() {
let now = Utc::now();
let future = now + Duration::days(1);
let conf = calculate_decay(0.8, Some(future), 14.0, now);
assert_eq!(conf, 0.8);
}
#[test]
fn decay_no_last_success_returns_min() {
let now = Utc::now();
let conf = calculate_decay(1.0, None, 14.0, now);
assert_eq!(conf, MIN_CONFIDENCE);
}
#[test]
fn next_state_idempotent() {
let now = Utc::now();
let stats = make_stats(LifecycleState::Draft, 1, 1, 1, 0, 1.0, false);
let s1 = next_state(&stats, now);
let s2 = next_state(&stats, now);
assert_eq!(s1, s2);
}
#[test]
fn promotion_full_ladder() {
let now = Utc::now();
let stats = make_stats(LifecycleState::Draft, 50, 45, 40, 0, 1.0, true);
assert_eq!(next_state(&stats, now), LifecycleState::Canonical);
}
#[test]
fn emerging_without_pin() {
let now = Utc::now();
let stats = make_stats(LifecycleState::Draft, 5, 4, 10, 1, 1.0, false);
assert_eq!(next_state(&stats, now), LifecycleState::Emerging);
}
#[test]
fn deprecation_from_low_success_rate() {
let now = Utc::now();
let stats = make_stats(LifecycleState::Emerging, 10, 2, 30, 10, 0.5, false);
assert_eq!(next_state(&stats, now), LifecycleState::Deprecated);
}
#[test]
fn pinned_floor_prevents_demotion() {
let now_fixed = Utc.with_ymd_and_hms(2026, 5, 25, 0, 0, 0).unwrap();
let stats = SkillStats {
lifecycle_state: LifecycleState::Stable,
pinned: true,
usage_count: 10,
success_count: 2,
failure_count: 8,
anchor_confidence: 0.5,
last_success_at: Some(now_fixed - Duration::days(120)),
first_successful_use_at: Some(now_fixed),
lifecycle_changed_at: now_fixed - Duration::hours(48),
..make_stats(LifecycleState::Stable, 10, 2, 30, 120, 0.5, true)
};
let state = next_state(&stats, now_fixed);
assert_ne!(state, LifecycleState::Deprecated);
}
#[test]
fn transition_allowed_dwell_within_24h_returns_false() {
let now = Utc::now();
let stats = SkillStats {
lifecycle_changed_at: now - Duration::hours(1),
pinned: false,
..make_stats(LifecycleState::Draft, 0, 0, 0, 0, 1.0, false)
};
assert!(!transition_allowed(
LifecycleState::Draft,
LifecycleState::Emerging,
&stats,
now,
));
}
#[test]
fn transition_allowed_identical_from_to_returns_false() {
let now = Utc::now();
let stats = make_stats(LifecycleState::Draft, 0, 0, 0, 0, 1.0, false);
assert!(!transition_allowed(
LifecycleState::Draft,
LifecycleState::Draft,
&stats,
now,
));
}
#[test]
fn transition_allowed_downgrade_pinned_blocked() {
let now = Utc::now();
let stats = SkillStats {
lifecycle_changed_at: now - Duration::hours(48),
pinned: true,
..make_stats(LifecycleState::Stable, 0, 0, 0, 0, 1.0, true)
};
assert!(!transition_allowed(
LifecycleState::Stable,
LifecycleState::Emerging,
&stats,
now,
));
}
#[test]
fn on_promotion_resets_anchor() {
let now = Utc::now();
let mut stats = make_stats(LifecycleState::Draft, 0, 0, 0, 0, 1.0, false);
let old_anchor = stats.anchor_confidence;
on_promotion(&mut stats, now);
assert!(stats.lifecycle_changed_at >= now - Duration::seconds(1));
assert!(stats.anchor_confidence <= old_anchor);
}
}