use std::path::{Path, PathBuf};
use eml_core::EmlModel;
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use crate::error::ProviderError;
use crate::retry::RetryConfig;
pub const RETRY_MODEL_FILENAME: &str = "retry_model.json";
pub fn retry_model_path(state_dir: &Path) -> PathBuf {
state_dir.join(RETRY_MODEL_FILENAME)
}
pub(crate) fn error_ordinal(err: &ProviderError) -> f64 {
match err {
ProviderError::RateLimited { .. } => 0.0,
ProviderError::Timeout => 1.0,
ProviderError::Http(_) => 2.0,
ProviderError::ServerError { status, .. } => {
match *status {
500 => 3.0,
502 => 4.0,
503 => 5.0,
504 => 6.0,
_ => 7.0,
}
}
_ => 8.0,
}
}
fn hour_of_day_normalized() -> f64 {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = now.as_secs();
let hour = (secs % 86400) / 3600;
hour as f64 / 24.0
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetryModel {
model: EmlModel,
#[serde(skip)]
fallback: RetryConfig,
#[serde(skip)]
persist_path: Option<PathBuf>,
}
impl RetryModel {
pub fn new() -> Self {
Self {
model: EmlModel::new(2, 3, 1),
fallback: RetryConfig::default(),
persist_path: None,
}
}
pub fn with_fallback(fallback: RetryConfig) -> Self {
Self {
model: EmlModel::new(2, 3, 1),
fallback,
persist_path: None,
}
}
pub fn with_persist_path(mut self, path: impl Into<PathBuf>) -> Self {
self.persist_path = Some(path.into());
self
}
pub fn persist_path(&self) -> Option<&Path> {
self.persist_path.as_deref()
}
pub fn delay_ms(&self, err: &ProviderError, attempt: u32) -> u64 {
if !self.model.is_trained() {
return crate::retry::compute_delay(&self.fallback, attempt).as_millis() as u64;
}
let inputs = [
error_ordinal(err) / 8.0, attempt as f64 / 10.0, hour_of_day_normalized(),
];
let predicted = self.model.predict_primary(&inputs);
let delay = predicted.clamp(100.0, 60_000.0);
delay as u64
}
pub fn record(&mut self, err: &ProviderError, attempt: u32, delay_ms: u64, succeeded: bool) {
self.record_by_ordinal(error_ordinal(err), attempt, delay_ms, succeeded);
}
pub fn record_by_ordinal(
&mut self,
ordinal: f64,
attempt: u32,
delay_ms: u64,
succeeded: bool,
) {
let inputs = [
ordinal / 8.0,
attempt as f64 / 10.0,
hour_of_day_normalized(),
];
let target = if succeeded {
delay_ms as f64
} else {
(delay_ms as f64 * 2.0).min(60_000.0)
};
self.model.record(&inputs, &[Some(target)]);
let n = self.model.training_sample_count();
if n >= 50 && n.is_multiple_of(25) {
let converged = self.model.train();
if converged {
debug!(samples = n, "RetryModel train converged");
}
self.maybe_persist();
}
}
pub fn train(&mut self) -> bool {
let ok = self.model.train();
if ok {
self.maybe_persist();
}
ok
}
pub fn is_trained(&self) -> bool {
self.model.is_trained()
}
pub fn training_sample_count(&self) -> usize {
self.model.training_sample_count()
}
pub fn params(&self) -> &[f64] {
self.model.params_slice()
}
pub fn install_weights(&mut self, params: &[f64], trained: bool) {
let slice = self.model.params_slice_mut();
assert_eq!(
slice.len(),
params.len(),
"param length mismatch: model has {}, got {}",
slice.len(),
params.len()
);
slice.copy_from_slice(params);
self.model.mark_trained(trained);
}
pub fn to_json(&self) -> String {
serde_json::to_string(self).expect("RetryModel serialization should not fail")
}
pub fn from_json(json: &str) -> Option<Self> {
serde_json::from_str(json).ok()
}
pub fn save_to_path(&self, path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, json.as_bytes())?;
std::fs::rename(&tmp, path)?;
debug!(path = %path.display(), trained = self.is_trained(), "saved RetryModel");
Ok(())
}
pub fn load_from_path(path: &Path) -> Option<Self> {
if !path.exists() {
return None;
}
match std::fs::read_to_string(path) {
Ok(data) => match Self::from_json(&data) {
Some(model) => {
debug!(
path = %path.display(),
trained = model.is_trained(),
"loaded RetryModel"
);
Some(model)
}
None => {
warn!(
path = %path.display(),
"failed to deserialize RetryModel, using defaults"
);
None
}
},
Err(e) => {
warn!(path = %path.display(), error = %e, "failed to read RetryModel file");
None
}
}
}
pub fn load_or_default(path: &Path) -> Self {
Self::load_from_path(path).unwrap_or_default()
}
pub fn maybe_persist(&self) {
let Some(path) = self.persist_path.as_ref() else {
return;
};
if let Err(e) = self.save_to_path(path) {
warn!(path = %path.display(), error = %e, "RetryModel persist failed");
}
}
pub fn save(&self) -> std::io::Result<()> {
let path = self.persist_path.as_ref().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"RetryModel has no persist_path configured",
)
})?;
self.save_to_path(path)
}
}
impl Default for RetryModel {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn untrained_uses_exponential_backoff() {
let model = RetryModel::new();
assert!(!model.is_trained());
let err = ProviderError::Timeout;
let delay = model.delay_ms(&err, 0);
assert!(delay >= 1000, "delay should be >= 1000ms, got {delay}");
assert!(
delay <= 1500,
"delay should be <= 1500ms (with jitter), got {delay}"
);
}
#[test]
fn untrained_delay_increases_with_attempt() {
let model = RetryModel::new();
let err = ProviderError::ServerError {
status: 503,
body: "unavailable".into(),
};
let d0 = model.delay_ms(&err, 0);
let d1 = model.delay_ms(&err, 1);
let d2 = model.delay_ms(&err, 2);
assert!(
d1 >= d0 || d2 > d0,
"delays should generally increase: {d0}, {d1}, {d2}"
);
}
#[test]
fn error_ordinal_mappings() {
assert!(
(error_ordinal(&ProviderError::RateLimited {
retry_after_ms: 1000
}))
.abs()
< 1e-9
);
assert!((error_ordinal(&ProviderError::Timeout) - 1.0).abs() < 1e-9);
assert!(
(error_ordinal(&ProviderError::ServerError {
status: 503,
body: String::new(),
}) - 5.0)
.abs()
< 1e-9
);
}
#[test]
fn record_increments_count() {
let mut model = RetryModel::new();
assert_eq!(model.training_sample_count(), 0);
model.record(&ProviderError::Timeout, 0, 1000, true);
assert_eq!(model.training_sample_count(), 1);
model.record(&ProviderError::Timeout, 1, 2000, false);
assert_eq!(model.training_sample_count(), 2);
}
#[test]
fn train_insufficient_data() {
let mut model = RetryModel::new();
for i in 0..10 {
model.record(&ProviderError::Timeout, i, 1000 * (i as u64 + 1), true);
}
assert!(!model.train());
assert!(!model.is_trained());
}
#[test]
fn serialization_roundtrip() {
let model = RetryModel::new();
let json = model.to_json();
let restored = RetryModel::from_json(&json).expect("should deserialize");
assert!(!restored.is_trained());
}
#[test]
fn from_json_invalid() {
assert!(RetryModel::from_json("not json").is_none());
}
#[test]
fn with_fallback_config() {
use std::time::Duration;
let config = RetryConfig {
max_retries: 5,
base_delay: Duration::from_millis(500),
max_delay: Duration::from_secs(10),
jitter_fraction: 0.0,
};
let model = RetryModel::with_fallback(config);
let delay = model.delay_ms(&ProviderError::Timeout, 0);
assert_eq!(delay, 500);
}
#[test]
fn default_impl() {
let model = RetryModel::default();
assert!(!model.is_trained());
}
#[test]
fn hour_of_day_in_range() {
let h = hour_of_day_normalized();
assert!((0.0..1.0).contains(&h), "hour should be in [0, 1), got {h}");
}
#[test]
fn retry_model_path_joins_filename() {
let p = retry_model_path(Path::new("/var/lib/clawft/state"));
assert_eq!(p, PathBuf::from("/var/lib/clawft/state/retry_model.json"));
}
#[test]
fn save_load_roundtrip_preserves_learned_weights() {
let dir = std::env::temp_dir().join(format!(
"retry_model_persist_{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = retry_model_path(&dir);
let mut model = RetryModel::new();
let mut params: Vec<f64> = model.params().to_vec();
for (i, p) in params.iter_mut().enumerate() {
*p = (i as f64 * 0.17).sin();
}
model.install_weights(¶ms, true);
assert!(model.is_trained());
model
.save_to_path(&path)
.expect("save_to_path should succeed");
assert!(path.exists());
drop(model);
let restored = RetryModel::load_from_path(&path).expect("should load after restart");
assert!(restored.is_trained(), "trained flag must survive restart");
assert_eq!(restored.params().len(), params.len());
for (i, (a, b)) in restored.params().iter().zip(params.iter()).enumerate() {
assert!(
(a - b).abs() < 1e-12,
"param[{i}] mismatch after restart: {a} vs {b}"
);
}
let err = ProviderError::Timeout;
let mut again = RetryModel::new();
again.install_weights(¶ms, true);
let d0 = again.delay_ms(&err, 1);
let d1 = restored.delay_ms(&err, 1);
assert_eq!(d0, d1, "delay predictions must match after restore");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_missing_returns_none_or_default() {
let path = std::env::temp_dir().join(format!(
"retry_model_missing_{}.json",
std::process::id()
));
let _ = std::fs::remove_file(&path);
assert!(RetryModel::load_from_path(&path).is_none());
let d = RetryModel::load_or_default(&path);
assert!(!d.is_trained());
}
#[test]
fn load_corrupt_returns_none() {
let path = std::env::temp_dir().join(format!(
"retry_model_corrupt_{}.json",
std::process::id()
));
std::fs::write(&path, "not valid json {{{").unwrap();
assert!(RetryModel::load_from_path(&path).is_none());
let _ = std::fs::remove_file(&path);
}
#[test]
fn persist_path_auto_saves_after_explicit_train_when_converged() {
let dir = std::env::temp_dir().join(format!(
"retry_model_autopath_{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = retry_model_path(&dir);
let mut model = RetryModel::new().with_persist_path(&path);
assert_eq!(model.persist_path(), Some(path.as_path()));
for i in 0..10 {
model.record(&ProviderError::Timeout, i, 1000 * (i as u64 + 1), true);
}
assert!(!model.train());
let params = model.params().to_vec();
model.install_weights(¶ms, true);
model.save().expect("save via persist_path");
assert!(path.exists());
let loaded = RetryModel::load_or_default(&path);
assert!(loaded.is_trained());
let _ = std::fs::remove_dir_all(&dir);
}
}