use serde::{Deserialize, Serialize};
use vtcode_config::optimization::OptimizationConfig;
use super::ledger::RewardLedger;
use super::signal::{RewardSignal, RlStrategy};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Action {
pub id: String,
}
#[derive(Debug, Clone, Copy)]
pub struct PolicyContext {
pub exploration: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RlSnapshot {
pub strategy: String,
pub recorded: u64,
}
#[derive(Debug, Clone)]
pub struct RlEngine {
strategy: RlStrategy,
exploration: f64,
latency_weight: f64,
ledger: RewardLedger,
}
impl RlEngine {
pub fn from_config(cfg: &OptimizationConfig) -> Self {
Self {
strategy: RlStrategy::parse(&cfg.rl.strategy),
exploration: cfg.rl.epsilon.max(0.0),
latency_weight: cfg.rl.latency_weight.clamp(0.0, 1.0),
ledger: RewardLedger::default(),
}
}
#[must_use]
pub fn select(&self, actions: &[Action], ctx: &PolicyContext) -> Option<usize> {
if actions.is_empty() {
return None;
}
let total = self.ledger.total().max(1) as f64;
let exploration = if self.strategy == RlStrategy::ActorCritic {
self.exploration * (1.0 / (1.0 + total))
} else {
ctx.exploration.max(0.0)
};
let mut best_idx = 0usize;
let mut best_ucb = f64::NEG_INFINITY;
for (idx, action) in actions.iter().enumerate() {
let ucb = self.ledger.ucb_for(&action.id, total, exploration);
if ucb > best_ucb {
best_ucb = ucb;
best_idx = idx;
}
}
Some(best_idx)
}
pub fn apply_reward(&mut self, action_id: &str, signal: RewardSignal) {
self.ledger.record(action_id, signal, self.latency_weight);
}
#[must_use]
pub fn ledger(&self) -> &RewardLedger {
&self.ledger
}
#[must_use]
pub fn snapshot(&self) -> RlSnapshot {
RlSnapshot {
strategy: format!("{:?}", self.strategy),
recorded: self.ledger.total(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use vtcode_config::optimization::OptimizationConfig;
fn cfg() -> OptimizationConfig {
OptimizationConfig::default()
}
#[test]
fn select_prefers_learned_success_over_untried() {
let mut engine = RlEngine::from_config(&cfg());
let actions = vec![Action { id: "edge".to_string() }, Action { id: "cloud".to_string() }];
let ctx = PolicyContext { exploration: 0.0 };
assert_eq!(engine.select(&actions, &ctx), Some(0));
engine.apply_reward("cloud", RewardSignal { success: false, latency_secs: 5.0, cost_usd: 0.1 });
assert_eq!(engine.select(&actions, &ctx), Some(0));
engine.apply_reward("edge", RewardSignal { success: true, latency_secs: 0.2, cost_usd: 0.001 });
assert_eq!(engine.select(&actions, &ctx), Some(0));
}
#[test]
fn empty_actions_returns_none() {
let engine = RlEngine::from_config(&cfg());
assert_eq!(engine.select(&[], &PolicyContext { exploration: 0.1 }), None);
}
#[test]
fn snapshot_reports_recorded_count() {
let mut engine = RlEngine::from_config(&cfg());
engine.apply_reward("x", RewardSignal { success: true, latency_secs: 0.1, cost_usd: 0.0 });
let snap = engine.snapshot();
assert_eq!(snap.recorded, 1);
assert_eq!(snap.strategy, "Bandit");
}
}