use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EvolvableComponent {
Memory,
Skills,
Harness,
Context,
Tools,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvolutionSignals {
#[serde(default)]
pub pressure: f64,
#[serde(default)]
pub evidence: u64,
#[serde(default)]
pub min_evidence: u64,
#[serde(default = "default_cost")]
pub cost: f64,
}
fn default_cost() -> f64 {
1.0
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComponentState {
pub component: EvolvableComponent,
#[serde(flatten)]
pub signals: EvolutionSignals,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EvolutionAction {
EvolveNow,
Defer,
Skip,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DeferReason {
InsufficientEvidence,
BudgetExhausted,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvolutionDecision {
pub component: EvolvableComponent,
pub action: EvolutionAction,
pub priority: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub defer_reason: Option<DeferReason>,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvolutionPolicy {
#[serde(default = "default_pressure_threshold")]
pub pressure_threshold: f64,
#[serde(default = "default_budget")]
pub budget: f64,
}
fn default_pressure_threshold() -> f64 {
0.2
}
fn default_budget() -> f64 {
f64::INFINITY
}
impl Default for EvolutionPolicy {
fn default() -> Self {
Self {
pressure_threshold: default_pressure_threshold(),
budget: default_budget(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvolutionPlan {
pub decisions: Vec<EvolutionDecision>,
pub spent: f64,
pub evolve_now: Vec<EvolvableComponent>,
}
fn effective_cost(cost: f64) -> f64 {
if cost > 0.0 {
cost
} else {
1.0
}
}
pub fn plan_evolution(components: &[ComponentState], policy: &EvolutionPolicy) -> EvolutionPlan {
struct Candidate {
idx: usize,
priority: f64,
cost: f64,
}
let mut decisions: Vec<Option<EvolutionDecision>> = vec![None; components.len()];
let mut candidates: Vec<Candidate> = Vec::new();
for (idx, c) in components.iter().enumerate() {
let s = &c.signals;
if s.pressure < policy.pressure_threshold {
decisions[idx] = Some(EvolutionDecision {
component: c.component,
action: EvolutionAction::Skip,
priority: 0.0,
defer_reason: None,
reason: format!(
"pressure {:.2} below threshold {:.2} — no cause to evolve",
s.pressure, policy.pressure_threshold
),
});
continue;
}
if s.evidence < s.min_evidence {
decisions[idx] = Some(EvolutionDecision {
component: c.component,
action: EvolutionAction::Defer,
priority: 0.0,
defer_reason: Some(DeferReason::InsufficientEvidence),
reason: format!(
"under pressure ({:.2}) but only {} of {} evidence — deferring to avoid \
overfitting",
s.pressure, s.evidence, s.min_evidence
),
});
continue;
}
let cost = effective_cost(s.cost);
candidates.push(Candidate {
idx,
priority: s.pressure / cost,
cost,
});
}
candidates.sort_by(|a, b| {
b.priority
.partial_cmp(&a.priority)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.idx.cmp(&b.idx))
});
let mut spent = 0.0;
let mut evolve_now: Vec<EvolvableComponent> = Vec::new();
for cand in &candidates {
let c = &components[cand.idx];
if spent + cand.cost <= policy.budget {
spent += cand.cost;
evolve_now.push(c.component);
decisions[cand.idx] = Some(EvolutionDecision {
component: c.component,
action: EvolutionAction::EvolveNow,
priority: cand.priority,
defer_reason: None,
reason: format!(
"pressure {:.2}, evidence sufficient, priority {:.3} — evolve now",
c.signals.pressure, cand.priority
),
});
} else {
decisions[cand.idx] = Some(EvolutionDecision {
component: c.component,
action: EvolutionAction::Defer,
priority: cand.priority,
defer_reason: Some(DeferReason::BudgetExhausted),
reason: format!(
"priority {:.3} but cost {:.2} exceeds remaining budget {:.2} — deferring",
cand.priority,
cand.cost,
policy.budget - spent
),
});
}
}
EvolutionPlan {
decisions: decisions.into_iter().map(|d| d.unwrap()).collect(),
spent,
evolve_now,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvolutionOutcome {
pub summary: String,
pub applied: bool,
}
impl EvolutionOutcome {
pub fn applied(summary: impl Into<String>) -> Self {
Self {
summary: summary.into(),
applied: true,
}
}
pub fn no_op(summary: impl Into<String>) -> Self {
Self {
summary: summary.into(),
applied: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvolutionStep {
pub component: EvolvableComponent,
pub ran: bool,
#[serde(default)]
pub applied: bool,
pub outcome: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvolutionCycleReport {
pub plan: EvolutionPlan,
pub steps: Vec<EvolutionStep>,
pub evolved: Vec<EvolvableComponent>,
}
pub async fn run_evolution_cycle<F, Fut>(
components: &[ComponentState],
policy: &EvolutionPolicy,
mut run: F,
) -> EvolutionCycleReport
where
F: FnMut(EvolvableComponent) -> Fut,
Fut: std::future::Future<Output = Result<EvolutionOutcome, String>>,
{
let plan = plan_evolution(components, policy);
let mut steps = Vec::new();
let mut evolved = Vec::new();
for &component in &plan.evolve_now {
match run(component).await {
Ok(outcome) => {
if outcome.applied {
evolved.push(component);
}
steps.push(EvolutionStep {
component,
ran: true,
applied: outcome.applied,
outcome: outcome.summary,
});
}
Err(e) => steps.push(EvolutionStep {
component,
ran: false,
applied: false,
outcome: e,
}),
}
}
EvolutionCycleReport {
plan,
steps,
evolved,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn comp(
component: EvolvableComponent,
pressure: f64,
evidence: u64,
min: u64,
cost: f64,
) -> ComponentState {
ComponentState {
component,
signals: EvolutionSignals {
pressure,
evidence,
min_evidence: min,
cost,
},
}
}
fn action_for(plan: &EvolutionPlan, c: EvolvableComponent) -> &EvolutionDecision {
plan.decisions.iter().find(|d| d.component == c).unwrap()
}
#[test]
fn no_pressure_is_skipped() {
let plan = plan_evolution(
&[comp(EvolvableComponent::Memory, 0.05, 100, 10, 1.0)],
&EvolutionPolicy::default(),
);
assert_eq!(
action_for(&plan, EvolvableComponent::Memory).action,
EvolutionAction::Skip
);
assert!(plan.evolve_now.is_empty());
}
#[test]
fn under_pressure_but_thin_evidence_defers() {
let plan = plan_evolution(
&[comp(EvolvableComponent::Skills, 0.8, 3, 20, 1.0)],
&EvolutionPolicy::default(),
);
let d = action_for(&plan, EvolvableComponent::Skills);
assert_eq!(d.action, EvolutionAction::Defer);
assert_eq!(d.defer_reason, Some(DeferReason::InsufficientEvidence));
}
#[test]
fn pressure_with_evidence_evolves() {
let plan = plan_evolution(
&[comp(EvolvableComponent::Skills, 0.8, 50, 20, 1.0)],
&EvolutionPolicy::default(),
);
assert_eq!(
action_for(&plan, EvolvableComponent::Skills).action,
EvolutionAction::EvolveNow
);
assert_eq!(plan.evolve_now, vec![EvolvableComponent::Skills]);
assert_eq!(plan.spent, 1.0);
}
#[test]
fn budget_admits_highest_value_density_first() {
let plan = plan_evolution(
&[
comp(EvolvableComponent::Memory, 0.5, 100, 10, 5.0), comp(EvolvableComponent::Skills, 0.9, 100, 10, 3.0), ],
&EvolutionPolicy {
pressure_threshold: 0.2,
budget: 3.0,
},
);
assert_eq!(plan.evolve_now, vec![EvolvableComponent::Skills]);
let mem = action_for(&plan, EvolvableComponent::Memory);
assert_eq!(mem.action, EvolutionAction::Defer);
assert_eq!(mem.defer_reason, Some(DeferReason::BudgetExhausted));
assert_eq!(plan.spent, 3.0);
}
#[test]
fn unbounded_budget_evolves_all_eligible() {
let plan = plan_evolution(
&[
comp(EvolvableComponent::Memory, 0.5, 100, 10, 5.0),
comp(EvolvableComponent::Skills, 0.9, 100, 10, 3.0),
comp(EvolvableComponent::Harness, 0.1, 100, 10, 1.0), ],
&EvolutionPolicy::default(),
);
assert_eq!(plan.evolve_now.len(), 2);
assert_eq!(plan.evolve_now[0], EvolvableComponent::Skills);
assert_eq!(
action_for(&plan, EvolvableComponent::Harness).action,
EvolutionAction::Skip
);
}
#[test]
fn nonpositive_cost_is_treated_as_unit() {
let plan = plan_evolution(
&[comp(EvolvableComponent::Tools, 0.6, 100, 0, 0.0)],
&EvolutionPolicy::default(),
);
let d = action_for(&plan, EvolvableComponent::Tools);
assert_eq!(d.action, EvolutionAction::EvolveNow);
assert!((d.priority - 0.6).abs() < 1e-9, "0.6 / 1.0");
}
#[tokio::test]
async fn cycle_dispatches_evolve_now_in_priority_order() {
let components = [
comp(EvolvableComponent::Memory, 0.5, 100, 10, 5.0), comp(EvolvableComponent::Skills, 0.9, 100, 10, 3.0), comp(EvolvableComponent::Harness, 0.1, 100, 10, 1.0), ];
let report =
run_evolution_cycle(&components, &EvolutionPolicy::default(), |c| async move {
Ok(EvolutionOutcome::applied(format!("ran {c:?}")))
})
.await;
assert_eq!(
report.evolved,
vec![EvolvableComponent::Skills, EvolvableComponent::Memory]
);
assert_eq!(report.steps.len(), 2);
assert!(report.steps.iter().all(|s| s.ran && s.applied));
}
#[tokio::test]
async fn cycle_continues_past_a_failing_runner() {
let components = [
comp(EvolvableComponent::Skills, 0.9, 100, 10, 1.0),
comp(EvolvableComponent::Memory, 0.5, 100, 10, 1.0),
];
let report =
run_evolution_cycle(&components, &EvolutionPolicy::default(), |c| async move {
if c == EvolvableComponent::Skills {
Err("evolve_skills failed".into())
} else {
Ok(EvolutionOutcome::applied("consolidated"))
}
})
.await;
assert_eq!(report.evolved, vec![EvolvableComponent::Memory]);
let skills_step = report
.steps
.iter()
.find(|s| s.component == EvolvableComponent::Skills)
.unwrap();
assert!(!skills_step.ran);
assert_eq!(skills_step.outcome, "evolve_skills failed");
}
#[tokio::test]
async fn a_no_op_runner_is_not_counted_as_evolved() {
let components = [
comp(EvolvableComponent::Skills, 0.9, 100, 10, 1.0),
comp(EvolvableComponent::Memory, 0.5, 100, 10, 1.0),
];
let report =
run_evolution_cycle(&components, &EvolutionPolicy::default(), |c| async move {
if c == EvolvableComponent::Skills {
Ok(EvolutionOutcome::no_op("all mutations pending approval"))
} else {
Ok(EvolutionOutcome::applied("consolidated"))
}
})
.await;
assert_eq!(report.evolved, vec![EvolvableComponent::Memory]);
let skills = report
.steps
.iter()
.find(|s| s.component == EvolvableComponent::Skills)
.unwrap();
assert!(skills.ran && !skills.applied, "{skills:?}");
}
#[tokio::test]
async fn cycle_with_nothing_to_do_runs_nothing() {
let components = [comp(EvolvableComponent::Memory, 0.05, 100, 10, 1.0)];
let report =
run_evolution_cycle(&components, &EvolutionPolicy::default(), |_c| async move {
Ok(EvolutionOutcome::applied("should not run"))
})
.await;
assert!(report.evolved.is_empty() && report.steps.is_empty());
}
}