use alloc::boxed::Box;
use super::{LawStep, Objective};
pub trait ControlLaw: Send {
fn step(&mut self, ctx: LawStep) -> usize;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LawKind {
HillClimb,
Aimd,
Proportional,
Multiplicative,
}
impl LawKind {
#[must_use]
pub fn build(self) -> Box<dyn ControlLaw> {
match self {
Self::HillClimb => Box::new(HillClimb::default()),
Self::Aimd => Box::new(Aimd::default()),
Self::Proportional => Box::new(Proportional::default()),
Self::Multiplicative => Box::new(Multiplicative),
}
}
pub fn parse(token: &str) -> Result<Self, &'static str> {
match token.trim().to_ascii_lowercase().as_str() {
"hillclimb" | "hill_climb" => Ok(Self::HillClimb),
"aimd" => Ok(Self::Aimd),
"proportional" | "pid" => Ok(Self::Proportional),
"multiplicative" | "gradient" => Ok(Self::Multiplicative),
_ => Err("law must be hillclimb|aimd|proportional|multiplicative"),
}
}
}
pub enum Law {
Builtin(LawKind),
Custom(Box<dyn ControlLaw>),
}
impl core::fmt::Debug for Law {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Builtin(kind) => write!(formatter, "Law::Builtin({kind:?})"),
Self::Custom(_) => formatter.write_str("Law::Custom(<dyn>)"),
}
}
}
impl Law {
#[must_use]
pub fn resolve(self) -> Box<dyn ControlLaw> {
match self {
Self::Builtin(kind) => kind.build(),
Self::Custom(law) => law,
}
}
#[must_use]
pub fn kind(&self) -> Option<LawKind> {
match self {
Self::Builtin(kind) => Some(*kind),
Self::Custom(_) => None,
}
}
}
#[derive(Debug)]
pub struct HillClimb {
direction: isize,
}
impl Default for HillClimb {
fn default() -> Self {
Self { direction: 1 }
}
}
impl ControlLaw for HillClimb {
fn step(&mut self, ctx: LawStep) -> usize {
if ctx.significant() {
if ctx.delta() < 0.0 {
self.direction = -self.direction;
}
} else {
self.direction = -self.direction;
}
ctx.stepped(self.direction)
}
}
#[derive(Debug)]
pub struct Aimd {
beta: f64,
}
impl Default for Aimd {
fn default() -> Self {
Self { beta: 0.85 }
}
}
impl ControlLaw for Aimd {
fn step(&mut self, ctx: LawStep) -> usize {
let Objective::Ceiling(ceiling) = ctx.objective else {
return ctx.current;
};
let margin = (ctx.coefficient_of_variation_threshold * ceiling).abs();
if ctx.signal > ceiling + margin {
ctx.clamp((ctx.current as f64 * self.beta).round() as usize)
} else if ctx.signal < ceiling - margin {
ctx.stepped(1)
} else {
ctx.current
}
}
}
#[derive(Debug)]
pub struct Proportional {
gain: f64,
}
impl Default for Proportional {
fn default() -> Self {
Self { gain: 0.5 }
}
}
impl ControlLaw for Proportional {
fn step(&mut self, ctx: LawStep) -> usize {
let Objective::Target(target) = ctx.objective else {
return ctx.current;
};
if target <= 0.0 {
return ctx.current;
}
let error = (target - ctx.signal) / target;
if error.abs() <= ctx.coefficient_of_variation_threshold {
return ctx.current;
}
let step = (ctx.current as f64 * self.gain * error).round() as isize;
ctx.stepped(step)
}
}
#[derive(Debug, Default)]
pub struct Multiplicative;
impl ControlLaw for Multiplicative {
fn step(&mut self, ctx: LawStep) -> usize {
let gradient = ctx.signal.clamp(0.5, 1.0);
let headroom = (ctx.current as f64).sqrt();
let next = ctx.current as f64 * gradient + headroom;
ctx.clamp(next.round() as usize)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::float_cmp)]
mod tests {
use super::*;
fn ctx(current: usize, signal: f64, prev: f64, objective: Objective) -> LawStep {
LawStep {
current,
signal,
prev_signal: prev,
objective,
cov: 0.0,
coefficient_of_variation_threshold: 0.0,
min: 1,
max: 1_024,
}
}
#[test]
fn hillclimb_continues_then_reverses() {
let mut law = HillClimb::default();
assert_eq!(law.step(ctx(8, 120.0, 100.0, Objective::Maximize)), 9);
assert_eq!(law.step(ctx(9, 110.0, 120.0, Objective::Maximize)), 8);
}
#[test]
fn hillclimb_dithers_not_drifts_on_noise() {
let mut law = HillClimb::default();
let mut current = 8usize;
for tick in 0..40 {
let signal = if tick % 2 == 0 { 101.0 } else { 99.0 };
let mut step = ctx(current, signal, 100.0, Objective::Maximize);
step.coefficient_of_variation_threshold = 0.10;
current = law.step(step);
assert!(
(7..=9).contains(¤t),
"stayed in ±1 band, got {current}"
);
}
}
#[test]
fn aimd_grows_under_and_cuts_over_ceiling() {
let mut law = Aimd::default();
assert_eq!(law.step(ctx(20, 0.60, 0.60, Objective::Ceiling(0.85))), 21);
assert_eq!(law.step(ctx(20, 0.95, 0.95, Objective::Ceiling(0.85))), 17);
}
#[test]
fn proportional_steps_toward_target() {
let mut law = Proportional::default();
assert_eq!(law.step(ctx(20, 2.5, 0.0, Objective::Target(5.0))), 25);
assert_eq!(law.step(ctx(20, 5.0, 0.0, Objective::Target(5.0))), 20);
assert_eq!(law.step(ctx(20, 10.0, 0.0, Objective::Target(5.0))), 10);
}
#[test]
fn multiplicative_grows_at_floor_and_backs_off_under_queue() {
let mut law = Multiplicative;
assert_eq!(law.step(ctx(16, 1.0, 0.5, Objective::Knee)), 20);
assert_eq!(law.step(ctx(16, 0.5, 1.0, Objective::Knee)), 12);
}
#[test]
fn multiplicative_is_stable_under_constant_gradient() {
let mut law = Multiplicative;
let mut current = 4usize;
let mut previous = 0usize;
for _ in 0..50 {
previous = current;
current = law.step(ctx(current, 0.75, 0.5, Objective::Knee));
}
assert_eq!(current, previous, "settles to a stable level");
assert!(current > 4, "grew from the seed");
assert!(current < 40, "no runaway");
}
#[test]
fn custom_law_is_a_drop_in() {
struct AlwaysSeven;
impl ControlLaw for AlwaysSeven {
fn step(&mut self, _ctx: LawStep) -> usize {
7
}
}
let mut law: Box<dyn ControlLaw> = Box::new(AlwaysSeven);
assert_eq!(law.step(ctx(1, 0.0, 0.0, Objective::Maximize)), 7);
}
#[test]
fn law_kind_parses_and_resolves() {
assert_eq!(LawKind::parse("gradient").unwrap(), LawKind::Multiplicative);
assert_eq!(LawKind::parse("AIMD").unwrap(), LawKind::Aimd);
assert!(LawKind::parse("bogus").is_err());
let mut law = LawKind::HillClimb.build();
let _ = law.step(ctx(4, 10.0, 1.0, Objective::Maximize));
}
}