use std::collections::HashMap;
use std::time::{Duration, Instant};
use super::selection::{simple_hash_fraction_for, simple_hash_index_for};
pub(super) const DEFAULT_WEIGHT: f64 = 1.0;
pub(super) const MIN_WEIGHT: f64 = 0.05;
pub(super) const RATE_LIMIT_DECAY: f64 = 0.3;
pub(super) fn default_missing_weight(configured_weights: &HashMap<String, f64>) -> f64 {
configured_weights
.values()
.copied()
.fold(None, |min, w| Some(min.map_or(w, |m: f64| m.min(w))))
.unwrap_or(DEFAULT_WEIGHT)
}
fn generate_recovery_jitter(agent: &str) -> Duration {
let hash: u64 = agent
.bytes()
.fold(0u64, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u64));
let max_secs = RECOVERY_JITTER_MAX.as_secs();
let jitter_secs = hash % max_secs;
Duration::from_secs(jitter_secs)
}
pub(super) const RECOVERY_DELAY: Duration = Duration::from_secs(60);
pub(super) const RECOVERY_JITTER_MAX: Duration = Duration::from_secs(30);
pub(super) const RECOVERY_RATE: f64 = 0.1;
#[derive(Debug, Clone)]
pub struct RateLimitState {
pub weight: f64,
pub last_limited_at: Option<Instant>,
pub recovery_jitter: Duration,
pub consecutive_hits: u32,
pub base_weight: f64,
}
impl Default for RateLimitState {
fn default() -> Self {
Self {
weight: DEFAULT_WEIGHT,
last_limited_at: None,
recovery_jitter: Duration::ZERO,
consecutive_hits: 0,
base_weight: DEFAULT_WEIGHT,
}
}
}
impl RateLimitState {
pub fn record_rate_limit(&mut self) {
self.consecutive_hits += 1;
self.weight = (self.weight * RATE_LIMIT_DECAY).max(MIN_WEIGHT);
self.last_limited_at = Some(Instant::now());
}
pub fn record_success(&mut self) {
self.consecutive_hits = 0;
self.weight = (self.weight + RECOVERY_RATE).min(self.base_weight);
}
pub fn maybe_recover(&mut self) {
if let Some(last) = self.last_limited_at {
let recovery_threshold = RECOVERY_DELAY + self.recovery_jitter;
if last.elapsed() >= recovery_threshold {
self.weight = (self.weight + RECOVERY_RATE).min(self.base_weight);
if self.weight >= self.base_weight {
self.last_limited_at = None;
self.recovery_jitter = Duration::ZERO;
self.consecutive_hits = 0;
}
}
}
}
pub fn is_limited(&self) -> bool {
self.weight < self.base_weight
}
}
#[derive(Debug, Clone, Default)]
pub struct AgentWeights {
pub states: HashMap<String, RateLimitState>,
pub base_weights: HashMap<String, f64>,
}
impl AgentWeights {
pub fn ensure_agents(&mut self, agents: &[String]) {
for agent in agents {
self.states.entry(agent.clone()).or_default();
}
}
pub fn ensure_agents_with_weights(
&mut self,
agents: &[String],
configured_weights: &std::collections::HashMap<String, f64>,
) {
for agent in agents {
let base = configured_weights
.get(agent)
.copied()
.unwrap_or_else(|| default_missing_weight(configured_weights));
let state = self
.states
.entry(agent.clone())
.or_insert_with(|| RateLimitState {
weight: base,
base_weight: base,
..Default::default()
});
state.base_weight = base;
if !state.is_limited() {
state.weight = base;
}
}
}
pub fn record_rate_limit(&mut self, agent: &str) {
let state = self.states.entry(agent.to_string()).or_default();
state.record_rate_limit();
state.recovery_jitter = generate_recovery_jitter(agent);
tracing::info!(
agent,
weight = state.weight,
hits = state.consecutive_hits,
jitter_secs = state.recovery_jitter.as_secs(),
"agent weight reduced (rate limit)"
);
}
pub fn record_success(&mut self, agent: &str) {
self.states
.entry(agent.to_string())
.or_default()
.record_success();
}
pub fn tick_recovery(&mut self) {
for (agent, state) in &mut self.states {
let was_limited = state.is_limited();
state.maybe_recover();
if was_limited && !state.is_limited() {
tracing::info!(agent, "agent weight fully recovered");
}
}
}
pub fn weighted_select(&self, agents: &[String], task_id: &str) -> Option<String> {
if agents.is_empty() {
return None;
}
let weights: Vec<f64> = agents
.iter()
.map(|a| {
self.states.get(a).map(|s| s.weight).unwrap_or_else(|| {
self.base_weights
.get(a)
.copied()
.unwrap_or_else(|| default_missing_weight(&self.base_weights))
})
})
.collect();
let total: f64 = weights.iter().sum();
if total <= 0.0 {
let idx = simple_hash_index_for(agents.len(), task_id);
return Some(agents[idx].clone());
}
let pick = simple_hash_fraction_for(task_id) * total;
let mut cumulative = 0.0;
for (i, w) in weights.iter().enumerate() {
cumulative += w;
if pick < cumulative {
return Some(agents[i].clone());
}
}
debug_assert!(!agents.is_empty(), "agents must not be empty");
Some(agents[agents.len() - 1].clone())
}
pub fn get_weight(&self, agent: &str) -> f64 {
self.states.get(agent).map(|s| s.weight).unwrap_or_else(|| {
self.base_weights
.get(agent)
.copied()
.unwrap_or_else(|| default_missing_weight(&self.base_weights))
})
}
pub fn snapshot(&self) -> Vec<(String, f64, u32)> {
let mut snap: Vec<_> = self
.states
.iter()
.map(|(a, s)| (a.clone(), s.weight, s.consecutive_hits))
.collect();
snap.sort_by(|a, b| a.0.cmp(&b.0));
snap
}
}