use serde::{Deserialize, Serialize};
use crate::hardware::EnvironmentSnapshot;
use crate::hnep::HnepProfile;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum DriftSeverity {
None,
Soft,
Hard,
}
impl DriftSeverity {
pub fn label(self) -> &'static str {
match self {
DriftSeverity::None => "NONE",
DriftSeverity::Soft => "SOFT",
DriftSeverity::Hard => "HARD",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DriftSignal {
pub field: String,
pub trained: String,
pub live: String,
pub severity: DriftSeverity,
pub note: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetrainRecommendation {
pub recommended: bool,
pub severity: DriftSeverity,
pub summary: String,
pub suggested_command: String,
pub targets: Vec<String>,
pub partial: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StalenessReport {
pub profile_label: String,
pub fingerprint: String,
pub live_fingerprint: Option<String>,
pub fingerprint_match: Option<bool>,
pub severity: DriftSeverity,
pub signals: Vec<DriftSignal>,
pub retrain: RetrainRecommendation,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StalenessPolicy {
pub soft_age_days: i64,
pub hard_age_days: i64,
pub watch_logical_cpus: bool,
}
impl Default for StalenessPolicy {
fn default() -> Self {
Self {
soft_age_days: 14,
hard_age_days: 90,
watch_logical_cpus: true,
}
}
}
fn max_severity(a: DriftSeverity, b: DriftSeverity) -> DriftSeverity {
use DriftSeverity::*;
match (a, b) {
(Hard, _) | (_, Hard) => Hard,
(Soft, _) | (_, Soft) => Soft,
_ => None,
}
}
pub fn assess_staleness(
profile: &HnepProfile,
live: &EnvironmentSnapshot,
live_fingerprint: Option<&str>,
policy: &StalenessPolicy,
) -> StalenessReport {
let mut signals = Vec::new();
if profile.environment.os != live.os {
signals.push(DriftSignal {
field: "os".into(),
trained: profile.environment.os.clone(),
live: live.os.clone(),
severity: DriftSeverity::Hard,
note: "OS family changed; specialization may not transfer".into(),
});
}
if profile.environment.arch != live.arch {
signals.push(DriftSignal {
field: "arch".into(),
trained: profile.environment.arch.clone(),
live: live.arch.clone(),
severity: DriftSeverity::Hard,
note: "Architecture changed; full retrain required".into(),
});
}
if policy.watch_logical_cpus && profile.environment.logical_cpus != live.logical_cpus {
signals.push(DriftSignal {
field: "logical_cpus".into(),
trained: profile.environment.logical_cpus.to_string(),
live: live.logical_cpus.to_string(),
severity: DriftSeverity::Soft,
note: "Logical CPU count drifted; concurrency / thread experiments may be stale"
.into(),
});
}
if !profile.environment.os_version.is_empty()
&& !live.os_version.is_empty()
&& profile.environment.os_version != live.os_version
{
signals.push(DriftSignal {
field: "os_version".into(),
trained: profile.environment.os_version.clone(),
live: live.os_version.clone(),
severity: DriftSeverity::Soft,
note: "OS version string differs; soft refresh recommended".into(),
});
}
if let (Ok(created), Ok(current)) = (
chrono::DateTime::parse_from_rfc3339(&profile.header.created_at),
chrono::DateTime::parse_from_rfc3339(&live.captured_at),
) {
let age = current.signed_duration_since(created).num_days();
if age > policy.hard_age_days {
signals.push(DriftSignal {
field: "age_days".into(),
trained: profile.header.created_at.clone(),
live: format!("{age} days"),
severity: DriftSeverity::Hard,
note: format!(
"Profile older than {} days; full retrain recommended",
policy.hard_age_days
),
});
} else if age > policy.soft_age_days {
signals.push(DriftSignal {
field: "age_days".into(),
trained: profile.header.created_at.clone(),
live: format!("{age} days"),
severity: DriftSeverity::Soft,
note: format!(
"Profile older than {} days; refresh when convenient",
policy.soft_age_days
),
});
}
}
let fingerprint_match = live_fingerprint.map(|fp| fp == profile.header.fingerprint);
if fingerprint_match == Some(false) {
signals.push(DriftSignal {
field: "fingerprint".into(),
trained: profile.header.fingerprint.clone(),
live: live_fingerprint.unwrap_or("?").into(),
severity: DriftSeverity::Hard,
note: "Fingerprint mismatch — wrong machine or topology class changed".into(),
});
}
let severity = signals
.iter()
.fold(DriftSeverity::None, |acc, s| max_severity(acc, s.severity));
let retrain = build_retrain_recommendation(profile, &signals, severity);
StalenessReport {
profile_label: profile.header.label.clone(),
fingerprint: profile.header.fingerprint.clone(),
live_fingerprint: live_fingerprint.map(|s| s.to_string()),
fingerprint_match,
severity,
signals,
retrain,
}
}
fn build_retrain_recommendation(
profile: &HnepProfile,
signals: &[DriftSignal],
severity: DriftSeverity,
) -> RetrainRecommendation {
if severity == DriftSeverity::None {
return RetrainRecommendation {
recommended: false,
severity,
summary: "Profile environment matches live host within policy".into(),
suggested_command: String::new(),
targets: Vec::new(),
partial: false,
};
}
let hard = severity == DriftSeverity::Hard;
let only_cpus = signals.iter().all(|s| s.field == "logical_cpus")
&& signals.iter().any(|s| s.field == "logical_cpus");
let only_soft_age = signals.iter().all(|s| s.field == "age_days")
&& !signals.iter().any(|s| s.severity == DriftSeverity::Hard);
if only_cpus {
let mut targets: Vec<String> = profile
.workloads
.iter()
.filter(|w| w.name.contains("concurrency") || w.name.contains("thread"))
.map(|w| w.name.clone())
.collect();
if !targets.iter().any(|t| t == "concurrency") {
targets.push("concurrency".into());
}
return RetrainRecommendation {
recommended: true,
severity,
summary: "Logical CPU count drifted — partial retrain of concurrency targets"
.into(),
suggested_command: format!(
"silicera train --output <path> --only {}",
targets.join(",")
),
targets,
partial: true,
};
}
if hard {
RetrainRecommendation {
recommended: true,
severity,
summary: "Hard environment drift — full retrain recommended before trusting winners"
.into(),
suggested_command: "silicera train --output out/profile.hnep".into(),
targets: Vec::new(),
partial: false,
}
} else if only_soft_age {
RetrainRecommendation {
recommended: true,
severity,
summary: "Profile aging past soft threshold — refresh when convenient".into(),
suggested_command: "silicera train --output out/profile.hnep".into(),
targets: Vec::new(),
partial: false,
}
} else {
RetrainRecommendation {
recommended: true,
severity,
summary: "Soft environment drift detected — refresh recommended".into(),
suggested_command: "silicera train --output out/profile.hnep".into(),
targets: Vec::new(),
partial: false,
}
}
}