#![deny(clippy::cast_lossless)]
use crate::control_types::{OverrideFlags, PidInput};
use crate::llmosafe_integration::{EscalationReason, SafetyDecision};
use crate::llmosafe_kernel::{
KernelError, FLAG_ANOMALY, FLAG_DECAYING, FLAG_DRIFTING, FLAG_LOW_CONFIDENCE, FLAG_STUCK,
U16_MAX_F32,
};
pub struct PidConfig {
pub kp: f32,
pub ki_fast: f32,
pub ki_slow: f32,
pub kd: f32,
pub kf: f32,
pub warn_gain: f32,
pub halt_gain: f32,
pub integrator_decay: f32,
pub step_change_threshold: f32,
}
impl Default for PidConfig {
fn default() -> Self {
Self {
kp: 1.0,
ki_fast: 0.5,
ki_slow: 0.3,
kd: 2.0,
kf: 0.3,
warn_gain: 0.5,
halt_gain: 1.0,
integrator_decay: 0.99,
step_change_threshold: 0.5,
}
}
}
impl PidConfig {
pub fn validate(&self) -> Result<(), &'static str> {
fn check(val: f32, min: f32, max: f32, name: &'static str) -> Result<(), &'static str> {
if val.is_nan() || !val.is_finite() {
return Err(name);
}
if val < min || val > max {
return Err(name);
}
Ok(())
}
check(self.kp, 0.0, 5.0, "kp must be in [0.0, 5.0]")?;
check(self.ki_fast, 0.0, 3.0, "ki_fast must be in [0.0, 3.0]")?;
check(self.ki_slow, 0.0, 3.0, "ki_slow must be in [0.0, 3.0]")?;
check(self.kd, 0.0, 5.0, "kd must be in [0.0, 5.0]")?;
check(self.kf, 0.0, 1.0, "kf must be in [0.0, 1.0]")?;
check(self.warn_gain, 0.0, 1.0, "warn_gain must be in [0.0, 1.0]")?;
check(self.halt_gain, 0.0, 1.0, "halt_gain must be in [0.0, 1.0]")?;
check(
self.integrator_decay,
0.0,
1.0,
"integrator_decay must be in [0.0, 1.0)",
)?;
check(
self.step_change_threshold,
0.0,
1.0,
"step_change_threshold must be in [0.0, 1.0]",
)?;
if self.integrator_decay >= 1.0 {
return Err("integrator_decay must be < 1.0");
}
if self.warn_gain >= self.halt_gain {
return Err("warn_gain must be < halt_gain");
}
if self.integrator_decay <= 0.899 {
return Err(
"integrator_decay must be > 0.9 (chronic must decay slower than acute=0.9)",
);
}
Ok(())
}
}
pub struct PidState {
pub acute_entropy: f32,
pub chronic_entropy: f32,
pub prev_pressure_norm: f32,
}
impl PidState {
pub fn new() -> Self {
Self {
acute_entropy: 0.0,
chronic_entropy: 0.0,
prev_pressure_norm: 0.0,
}
}
pub fn reset(&mut self) {
self.acute_entropy = 0.0;
self.chronic_entropy = 0.0;
self.prev_pressure_norm = 0.0;
}
}
impl Default for PidState {
fn default() -> Self {
Self::new()
}
}
struct EffectiveGains {
kp: f32,
ki_fast: f32,
ki_slow: f32,
kd: f32,
kf: f32,
}
fn modulate_gains(config: &PidConfig, flags: u8) -> EffectiveGains {
EffectiveGains {
kp: config.kp * if flags & FLAG_STUCK != 0 { 1.3 } else { 1.0 },
ki_fast: config.ki_fast * if flags & FLAG_DECAYING != 0 { 1.3 } else { 1.0 },
ki_slow: config.ki_slow * if flags & FLAG_STUCK != 0 { 1.3 } else { 1.0 },
kd: config.kd
* if flags & FLAG_ANOMALY != 0 {
1.5
} else if flags & FLAG_DRIFTING != 0 {
1.2
} else {
1.0
},
kf: config.kf
* if flags & FLAG_LOW_CONFIDENCE != 0 {
1.5
} else {
1.0
},
}
}
fn compute_pid_score_inner(input: &PidInput, config: &PidConfig, state: &mut PidState) -> f32 {
let entropy_norm = input.e_sift.clamp(0.0, 1.0);
let trend_abs_norm = ((input.trend.abs() as f32) / U16_MAX_F32).clamp(0.0, 1.0);
let pressure_norm = (f32::from(input.pressure) / 100.0_f32).clamp(0.0, 1.0);
let f_norm = input.classifier_prob.clamp(0.0, 1.0);
let body_norm = input.e_body.clamp(0.0, 1.0);
let mem_norm = input.e_mem.clamp(0.0, 1.0);
let kernel_norm = input.e_kernel.clamp(0.0, 1.0);
let acute_input = (entropy_norm + body_norm * 0.5 + kernel_norm * 0.3).clamp(0.0, 1.0);
let chronic_input = (entropy_norm + mem_norm * 0.5 + kernel_norm * 0.3).clamp(0.0, 1.0);
let eff = modulate_gains(config, input.detection_flags);
let pressure_delta = (pressure_norm - state.prev_pressure_norm).abs();
let kp_effective = if pressure_delta > config.step_change_threshold {
eff.kp * 2.0
} else {
eff.kp
};
let risk_estimate = (kp_effective * pressure_norm)
+ (eff.ki_fast * state.acute_entropy + eff.ki_slow * state.chronic_entropy)
+ (eff.kd * trend_abs_norm)
+ (eff.kf * f_norm);
if risk_estimate < config.halt_gain {
state.acute_entropy = (state.acute_entropy * 0.9 + acute_input).clamp(0.0, 1.0);
state.chronic_entropy =
(state.chronic_entropy * config.integrator_decay + chronic_input).clamp(0.0, 1.0);
} else {
state.acute_entropy *= 0.999;
state.chronic_entropy *= 0.999;
}
let p_term = kp_effective * pressure_norm;
let i_term = eff.ki_fast * state.acute_entropy + eff.ki_slow * state.chronic_entropy;
let d_term = eff.kd * trend_abs_norm;
let f_term = eff.kf * f_norm;
let risk = (p_term + i_term + d_term + f_term).clamp(0.0, 1.0);
state.prev_pressure_norm = pressure_norm;
risk
}
pub fn compute_pid_score(input: &PidInput, config: &PidConfig, state: &mut PidState) -> f32 {
let mut risk = compute_pid_score_inner(input, config, state);
if input.has_bias {
risk = risk.max(config.halt_gain + 0.001);
}
risk.clamp(0.0, 1.0)
}
pub fn compute_pid_score_pure(input: &PidInput, config: &PidConfig, state: &mut PidState) -> f32 {
compute_pid_score_inner(input, config, state)
}
pub fn apply_safety_overrides(risk: f32, flags: OverrideFlags, config: &PidConfig) -> f32 {
#[cfg(feature = "dal")]
{
let mut result = risk;
if flags.contains(OverrideFlags::BIAS) {
result = result.max(config.halt_gain + 0.001);
}
if flags.contains(OverrideFlags::EXHAUSTED) {
result = 1.0;
}
if flags.contains(OverrideFlags::KERNEL_UNSTABLE) {
result = result.max(config.halt_gain);
}
result.clamp(0.0, 1.0)
}
#[cfg(not(feature = "dal"))]
{
let _ = flags;
let _ = config;
risk
}
}
pub fn pid_risk_to_decision(risk: f32, config: &PidConfig) -> SafetyDecision {
if risk.is_nan() {
return SafetyDecision::Halt(KernelError::CognitiveInstability, 0);
}
let risk = risk.clamp(0.0, 1.0);
if risk >= config.halt_gain {
SafetyDecision::Halt(KernelError::CognitiveInstability, 30000)
} else if risk >= config.warn_gain {
SafetyDecision::Escalate {
entropy: 0,
reason: EscalationReason::Custom("PID risk elevated"),
cooldown_ms: 5000,
}
} else {
SafetyDecision::Proceed
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pid_config_default_validates() {
let config = PidConfig::default();
assert!(config.validate().is_ok());
}
#[test]
fn pid_config_rejects_nan_kp() {
let config = PidConfig {
kp: f32::NAN,
..PidConfig::default()
};
assert!(config.validate().is_err());
}
#[test]
fn pid_config_rejects_inf_kd() {
let config = PidConfig {
kd: f32::INFINITY,
..PidConfig::default()
};
assert!(config.validate().is_err());
}
#[test]
fn pid_config_rejects_negative_ki_fast() {
let config = PidConfig {
ki_fast: -0.1,
..PidConfig::default()
};
assert!(config.validate().is_err());
}
#[test]
fn pid_config_rejects_kp_out_of_range_high() {
let config = PidConfig {
kp: 5.1,
..PidConfig::default()
};
assert!(config.validate().is_err());
}
#[test]
fn pid_config_rejects_kf_out_of_range_high() {
let config = PidConfig {
kf: 1.1,
..PidConfig::default()
};
assert!(config.validate().is_err());
}
#[test]
fn pid_config_rejects_warn_gain_ge_halt_gain() {
let config = PidConfig {
warn_gain: 1.0,
halt_gain: 1.0,
..PidConfig::default()
};
assert!(config.validate().is_err());
let config2 = PidConfig {
warn_gain: 0.9,
halt_gain: 0.8,
..PidConfig::default()
};
assert!(config2.validate().is_err());
}
#[test]
fn pid_config_rejects_integrator_decay_at_1() {
let config = PidConfig {
integrator_decay: 1.0,
..PidConfig::default()
};
assert!(config.validate().is_err());
}
#[test]
fn pid_config_rejects_integrator_decay_below_acute() {
let config = PidConfig {
integrator_decay: 0.5,
..PidConfig::default()
};
assert!(config.validate().is_err());
}
#[test]
fn pid_config_rejects_step_change_threshold_out_of_range() {
let config = PidConfig {
step_change_threshold: 1.1,
..PidConfig::default()
};
assert!(config.validate().is_err());
}
#[test]
fn pid_config_boundary_values_validate() {
let config = PidConfig {
kp: 0.0,
ki_fast: 0.0,
ki_slow: 0.0,
kd: 0.0,
kf: 0.0,
warn_gain: 0.0,
halt_gain: 1.0,
integrator_decay: 0.9, step_change_threshold: 0.0,
};
assert!(config.validate().is_ok());
let config_max = PidConfig {
kp: 5.0,
ki_fast: 3.0,
ki_slow: 3.0,
kd: 5.0,
kf: 1.0,
warn_gain: 0.99,
halt_gain: 1.0,
integrator_decay: 0.999,
step_change_threshold: 1.0,
};
assert!(config_max.validate().is_ok());
}
#[test]
fn pid_state_new_zeros_all() {
let state = PidState::new();
assert_eq!(state.acute_entropy, 0.0);
assert_eq!(state.chronic_entropy, 0.0);
assert_eq!(state.prev_pressure_norm, 0.0);
}
#[test]
fn pid_state_reset_zeros_all() {
let mut state = PidState {
acute_entropy: 0.5,
chronic_entropy: 0.8,
prev_pressure_norm: 0.3,
};
state.reset();
assert_eq!(state.acute_entropy, 0.0);
assert_eq!(state.chronic_entropy, 0.0);
assert_eq!(state.prev_pressure_norm, 0.0);
}
#[test]
fn zero_input_produces_low_risk() {
let config = PidConfig::default();
let mut state = PidState::new();
let risk = compute_pid_score(
&PidInput::new(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, false, 0, 0),
&config,
&mut state,
);
assert!((risk - 0.0).abs() < 0.01, "risk={}", risk);
}
#[test]
fn max_entropy_produces_i_term() {
let config = PidConfig::default();
let mut state = PidState::new();
let risk = compute_pid_score(
&PidInput::new(0.0, 1.0, 0.0, 0.0, 0.0, 1.0, false, 0, 0),
&config,
&mut state,
);
assert!(risk > 0.4, "risk should have I-term contribution: {}", risk);
let risk2 = compute_pid_score(
&PidInput::new(0.0, 1.0, 0.0, 0.0, 0.0, 1.0, false, 0, 0),
&config,
&mut state,
);
assert!(risk2 > 0.4, "risk2 should also have I-term contribution");
}
#[test]
fn integrator_decays_over_clean_cycles() {
let config = PidConfig::default();
let mut state = PidState::new();
compute_pid_score(
&PidInput::new(0.0, 1.0, 0.0, 0.0, 0.0, 1.0, false, 0, 0),
&config,
&mut state,
);
let after_spike = state.chronic_entropy;
assert!(
after_spike > 0.9,
"integrator should saturate quickly: {}",
after_spike
);
for _ in 0..50 {
compute_pid_score(
&PidInput::new(0.0, 0.0, 0.0, 0.0, 0.0, 1.0, false, 0, 0),
&config,
&mut state,
);
}
assert!(
state.chronic_entropy < after_spike,
"integrator should decay: was {}, now {}",
after_spike,
state.chronic_entropy
);
}
#[test]
fn step_change_doubles_kp() {
let config = PidConfig::default();
let mut state = PidState::new();
let risk_with_step = compute_pid_score(
&PidInput::new(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, false, 0, 80),
&config,
&mut state,
);
assert!(
(risk_with_step - 1.0).abs() < 0.01,
"risk={}",
risk_with_step
);
let risk_no_step = compute_pid_score(
&PidInput::new(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, false, 0, 80),
&config,
&mut state,
); assert!(
risk_no_step < risk_with_step,
"step-change risk should be higher (with_step={}, no_step={})",
risk_with_step,
risk_no_step
);
}
#[test]
fn bias_override_forces_halt_level() {
let config = PidConfig::default();
let mut state = PidState::new();
let risk = compute_pid_score(
&PidInput::new(0.0, 0.0, 0.0, 0.0, 0.0, 1.0, true, 0, 0),
&config,
&mut state,
);
assert!(
risk >= config.halt_gain,
"bias should force >= halt_gain: got {}",
risk
);
}
#[test]
fn risk_never_exceeds_1_with_max_gains() {
let config = PidConfig {
kp: 5.0,
ki_fast: 3.0,
ki_slow: 3.0,
kd: 5.0,
kf: 1.0,
..PidConfig::default()
};
let mut state = PidState::new();
state.acute_entropy = 1.0;
state.chronic_entropy = 1.0;
let risk = compute_pid_score(
&PidInput::new(
0.0,
1.0,
0.0,
0.0,
65535.0,
0.0,
false,
FLAG_STUCK | FLAG_ANOMALY | FLAG_LOW_CONFIDENCE,
100,
),
&config,
&mut state,
);
assert!(risk <= 1.0, "risk must be clamped: got {}", risk);
assert!(risk.is_finite());
}
#[test]
fn monotonic_higher_entropy_higher_risk() {
let config = PidConfig::default();
let mut state_low = PidState::new();
let mut state_high = PidState::new();
let _ = compute_pid_score(
&PidInput::new(
0.0,
f32::from(1000u16) / U16_MAX_F32,
0.0,
0.0,
0.0,
0.8,
false,
0,
30,
),
&config,
&mut state_low,
);
let _ = compute_pid_score(
&PidInput::new(
0.0,
f32::from(1000u16) / U16_MAX_F32,
0.0,
0.0,
0.0,
0.8,
false,
0,
30,
),
&config,
&mut state_high,
);
let risk_low = compute_pid_score(
&PidInput::new(
0.0,
f32::from(5000u16) / U16_MAX_F32,
0.0,
0.0,
0.0,
0.8,
false,
0,
30,
),
&config,
&mut state_low,
);
let risk_high = compute_pid_score(
&PidInput::new(
0.0,
f32::from(50000u16) / U16_MAX_F32,
0.0,
0.0,
0.0,
0.8,
false,
0,
30,
),
&config,
&mut state_high,
);
assert!(
risk_high > risk_low,
"higher entropy should produce higher risk: {} vs {}",
risk_low,
risk_high
);
}
#[test]
fn anti_windup_freezes_integrator_when_risk_at_halt() {
let mut state = PidState::new();
state.acute_entropy = 1.0;
state.chronic_entropy = 1.0;
let forced_config = PidConfig {
kp: 5.0,
kd: 5.0,
ki_fast: 3.0,
ki_slow: 3.0,
..PidConfig::default()
};
let risk = compute_pid_score(
&PidInput::new(
0.0,
1.0,
0.0,
0.0,
65535.0,
0.0,
false,
FLAG_STUCK | FLAG_ANOMALY,
100,
),
&forced_config,
&mut state,
);
assert!(risk >= 1.0);
let acute_before = state.acute_entropy;
let chronic_before = state.chronic_entropy;
let _ = compute_pid_score(
&PidInput::new(0.0, 1.0, 0.0, 0.0, 65535.0, 0.0, false, FLAG_STUCK, 100),
&forced_config,
&mut state,
);
assert!(
state.acute_entropy < acute_before,
"acute integrator should bleed during windup"
);
assert!(
state.chronic_entropy < chronic_before,
"chronic integrator should bleed during windup"
);
}
#[test]
fn anti_windup_resumes_when_risk_drops() {
let config = PidConfig::default();
let mut state = PidState::new();
for _ in 0..5 {
compute_pid_score(
&PidInput::new(0.0, 1.0, 0.0, 0.0, 0.0, 0.5, false, 0, 30),
&config,
&mut state,
);
}
let acute_before = state.acute_entropy;
let _ = compute_pid_score(
&PidInput::new(0.0, 0.0, 0.0, 0.0, 0.0, 1.0, false, 0, 0),
&config,
&mut state,
);
assert!(
state.acute_entropy < acute_before,
"integrator should decay on clean input: {} -> {}",
acute_before,
state.acute_entropy
);
}
#[test]
fn sidechain_anomaly_boosts_kd() {
let config = PidConfig::default();
let mut state_clean = PidState::new();
let mut state_anomaly = PidState::new();
let risk_clean = compute_pid_score(
&PidInput::new(0.0, 0.0, 0.0, 0.0, 10000.0, 0.0, false, 0, 0),
&config,
&mut state_clean,
);
let risk_anomaly = compute_pid_score(
&PidInput::new(0.0, 0.0, 0.0, 0.0, 10000.0, 0.0, false, FLAG_ANOMALY, 0),
&config,
&mut state_anomaly,
);
assert!(
risk_anomaly > risk_clean,
"FLAG_ANOMALY should boost Kd: clean={}, anomaly={}",
risk_clean,
risk_anomaly
);
}
#[test]
fn sidechain_stuck_doubles_ki() {
let config = PidConfig::default();
let mut state_clean = PidState::new();
let mut state_stuck = PidState::new();
for _ in 0..3 {
compute_pid_score(
&PidInput::new(0.0, 0.3, 0.0, 0.0, 0.0, 0.0, false, 0, 0),
&config,
&mut state_clean,
);
compute_pid_score(
&PidInput::new(0.0, 0.3, 0.0, 0.0, 0.0, 0.0, false, 0, 0),
&config,
&mut state_stuck,
);
}
let risk_clean = compute_pid_score(
&PidInput::new(0.0, 0.3, 0.0, 0.0, 0.0, 0.0, false, 0, 0),
&config,
&mut state_clean,
);
let risk_stuck = compute_pid_score(
&PidInput::new(0.0, 0.3, 0.0, 0.0, 0.0, 0.0, false, FLAG_STUCK, 0),
&config,
&mut state_stuck,
);
assert!(
risk_stuck > risk_clean,
"FLAG_STUCK should boost Ki: clean={}, stuck={}",
risk_clean,
risk_stuck
);
}
#[test]
fn sidechain_no_flags_identity_modulation() {
let config = PidConfig::default();
let mut state_a = PidState::new();
let mut state_b = PidState::new();
let risk_a = compute_pid_score(
&PidInput::new(
0.0,
f32::from(10000u16) / U16_MAX_F32,
0.0,
0.0,
5000.0,
0.8,
false,
0,
30,
),
&config,
&mut state_a,
);
let risk_b = compute_pid_score(
&PidInput::new(
0.0,
f32::from(10000u16) / U16_MAX_F32,
0.0,
0.0,
5000.0,
0.8,
false,
0,
30,
),
&config,
&mut state_b,
);
assert!(
(risk_a - risk_b).abs() < 0.001,
"same input, same state → same risk"
);
}
#[test]
fn acute_integrator_rises_faster_than_chronic() {
let config = PidConfig::default();
let mut state = PidState::new();
state.acute_entropy = 0.5;
state.chronic_entropy = 0.5;
for _ in 0..5 {
compute_pid_score(
&PidInput::new(0.0, 0.5, 0.0, 0.0, 0.0, 0.0, false, 0, 0),
&config,
&mut state,
);
}
assert!(
(state.acute_entropy - 1.0).abs() < 0.01,
"acute should be near 1.0: {}",
state.acute_entropy
);
assert!(
(state.chronic_entropy - 1.0).abs() < 0.01,
"chronic should be near 1.0: {}",
state.chronic_entropy
);
}
#[test]
fn acute_integrator_decays_faster_than_chronic() {
let config = PidConfig::default();
let mut state = PidState::new();
for _ in 0..100 {
compute_pid_score(
&PidInput::new(0.0, 1.0, 0.0, 0.0, 0.0, 1.0, false, 0, 0),
&config,
&mut state,
);
}
let acute_peak = state.acute_entropy;
let chronic_peak = state.chronic_entropy;
for _ in 0..20 {
compute_pid_score(
&PidInput::new(0.0, 0.0, 0.0, 0.0, 0.0, 1.0, false, 0, 0),
&config,
&mut state,
);
}
let acute_decay_ratio = acute_peak / state.acute_entropy.max(0.001);
let chronic_decay_ratio = chronic_peak / state.chronic_entropy.max(0.001);
assert!(
acute_decay_ratio > chronic_decay_ratio * 0.8,
"acute should decay faster (higher ratio): acute={}, chronic={}",
acute_decay_ratio,
chronic_decay_ratio
);
}
#[test]
fn risk_below_warn_is_proceed() {
let config = PidConfig::default();
let decision = pid_risk_to_decision(0.3, &config);
assert!(matches!(decision, SafetyDecision::Proceed));
}
#[test]
fn risk_at_warn_is_escalate() {
let config = PidConfig::default();
let decision = pid_risk_to_decision(0.5, &config);
assert!(matches!(decision, SafetyDecision::Escalate { .. }));
}
#[test]
fn risk_at_halt_is_halt() {
let config = PidConfig::default();
let decision = pid_risk_to_decision(1.0, &config);
assert!(matches!(decision, SafetyDecision::Halt(..)));
}
#[test]
fn risk_above_halt_is_halt() {
let config = PidConfig::default();
let mut state = PidState::new();
let risk = compute_pid_score(
&PidInput::new(0.0, 1.0, 0.0, 0.0, 65535.0, 0.0, false, 0, 100),
&config,
&mut state,
);
assert!(risk >= 1.0);
let decision = pid_risk_to_decision(risk, &config);
assert!(matches!(decision, SafetyDecision::Halt(..)));
}
#[test]
fn pid_decision_severity_monotonic() {
let config = PidConfig::default();
let d_low = pid_risk_to_decision(0.3, &config);
let d_mid = pid_risk_to_decision(0.5, &config);
let d_high = pid_risk_to_decision(1.0, &config);
assert!(d_high.severity() >= d_mid.severity());
assert!(d_mid.severity() >= d_low.severity());
}
#[test]
fn pure_score_no_bias_override() {
let config = PidConfig::default();
let mut state = PidState::new();
let risk = compute_pid_score_pure(
&PidInput::new(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, false, 0, 0),
&config,
&mut state,
);
assert!(
(risk - 0.0).abs() < 0.01,
"pure risk should be ~0: {}",
risk
);
}
#[test]
fn pure_score_identical_to_original_without_bias() {
let config = PidConfig::default();
let mut state_pure = PidState::new();
let mut state_orig = PidState::new();
let r_pure = compute_pid_score_pure(
&PidInput::new(0.0, 0.5, 0.0, 0.0, 5000.0, 0.7, false, 0, 50),
&config,
&mut state_pure,
);
let r_orig = compute_pid_score(
&PidInput::new(0.0, 0.5, 0.0, 0.0, 5000.0, 0.7, false, 0, 50),
&config,
&mut state_orig,
);
assert!(
(r_pure - r_orig).abs() < 0.001,
"pure and original should match without bias: pure={}, orig={}",
r_pure,
r_orig
);
}
#[test]
fn safety_overrides_no_flags_passthrough() {
let config = PidConfig::default();
let result = apply_safety_overrides(0.3, OverrideFlags::empty(), &config);
assert!((result - 0.3).abs() < 0.001, "no flags: {}", result);
}
#[cfg(feature = "dal")]
#[test]
fn safety_overrides_bias_forces_halt() {
let config = PidConfig::default();
let result = apply_safety_overrides(0.0, OverrideFlags::BIAS, &config);
assert!(
result >= config.halt_gain,
"bias should force >= halt_gain: {}",
result
);
}
#[cfg(feature = "dal")]
#[test]
fn safety_overrides_exhausted_forces_max() {
let config = PidConfig::default();
let result = apply_safety_overrides(0.0, OverrideFlags::EXHAUSTED, &config);
assert!(
(result - 1.0).abs() < 0.001,
"exhausted should force 1.0: {}",
result
);
}
#[cfg(feature = "dal")]
#[test]
fn safety_overrides_kernel_unstable_forces_halt() {
let config = PidConfig::default();
let result = apply_safety_overrides(0.0, OverrideFlags::KERNEL_UNSTABLE, &config);
assert!(
result >= config.halt_gain,
"kernel unstable should force >= halt_gain: {}",
result
);
}
#[cfg(feature = "dal")]
#[test]
fn safety_overrides_bias_exhausted_combined() {
let config = PidConfig::default();
let result =
apply_safety_overrides(0.0, OverrideFlags::BIAS | OverrideFlags::EXHAUSTED, &config);
assert!((result - 1.0).abs() < 0.001, "exhausted+bias: {}", result);
}
#[cfg(feature = "dal")]
#[test]
fn safety_overrides_dal_a_monotonic() {
let config = PidConfig::default();
let r_none = apply_safety_overrides(0.0, OverrideFlags::empty(), &config);
let r_bias = apply_safety_overrides(0.0, OverrideFlags::BIAS, &config);
let r_exhausted = apply_safety_overrides(0.0, OverrideFlags::EXHAUSTED, &config);
let r_kernel = apply_safety_overrides(0.0, OverrideFlags::KERNEL_UNSTABLE, &config);
assert!(r_none < r_bias, "none={} < bias={}", r_none, r_bias);
assert!(
r_bias <= r_exhausted,
"bias={} <= exhausted={}",
r_bias,
r_exhausted
);
assert!(r_none < r_kernel, "none={} < kernel={}", r_none, r_kernel);
}
#[test]
fn multi_channel_body_signal_increases_risk() {
let config = PidConfig::default();
let mut state_clean = PidState::new();
let mut state_body = PidState::new();
let risk_clean = compute_pid_score(
&PidInput::new(0.0, 0.3, 0.0, 0.0, 0.0, 1.0, false, 0, 0),
&config,
&mut state_clean,
);
let risk_body = compute_pid_score(
&PidInput::new(0.5, 0.3, 0.0, 0.0, 0.0, 1.0, false, 0, 0),
&config,
&mut state_body,
);
assert!(
risk_body > risk_clean,
"e_body=0.5 must increase risk: clean={}, body={}",
risk_clean,
risk_body
);
}
#[test]
fn multi_channel_memory_signal_increases_risk() {
let config = PidConfig::default();
let mut state_clean = PidState::new();
let mut state_mem = PidState::new();
let risk_clean = compute_pid_score(
&PidInput::new(0.0, 0.3, 0.0, 0.0, 0.0, 1.0, false, 0, 0),
&config,
&mut state_clean,
);
let risk_mem = compute_pid_score(
&PidInput::new(0.0, 0.3, 0.4, 0.0, 0.0, 1.0, false, 0, 0),
&config,
&mut state_mem,
);
assert!(
risk_mem > risk_clean,
"e_mem=0.4 must increase risk: clean={}, mem={}",
risk_clean,
risk_mem
);
}
#[test]
fn multi_channel_kernel_signal_increases_risk() {
let config = PidConfig::default();
let mut state_clean = PidState::new();
let mut state_kernel = PidState::new();
let risk_clean = compute_pid_score(
&PidInput::new(0.0, 0.3, 0.0, 0.0, 0.0, 1.0, false, 0, 0),
&config,
&mut state_clean,
);
let risk_kernel = compute_pid_score(
&PidInput::new(0.0, 0.3, 0.0, 0.6, 0.0, 1.0, false, 0, 0),
&config,
&mut state_kernel,
);
assert!(
risk_kernel > risk_clean,
"e_kernel=0.6 must increase risk: clean={}, kernel={}",
risk_clean,
risk_kernel
);
}
#[test]
fn sidechain_drifting_boosts_kd() {
let config = PidConfig::default();
let mut state_clean = PidState::new();
let mut state_drift = PidState::new();
let risk_clean = compute_pid_score(
&PidInput::new(0.0, 0.0, 0.0, 0.0, 10000.0, 0.0, false, 0, 0),
&config,
&mut state_clean,
);
let risk_drift = compute_pid_score(
&PidInput::new(0.0, 0.0, 0.0, 0.0, 10000.0, 0.0, false, FLAG_DRIFTING, 0),
&config,
&mut state_drift,
);
assert!(
risk_drift > risk_clean,
"FLAG_DRIFTING must boost Kd by 1.2: clean={}, drift={}",
risk_clean,
risk_drift
);
}
#[test]
fn sidechain_decaying_boosts_ki_fast() {
let config = PidConfig::default();
let mut state_clean = PidState::new();
let mut state_decay = PidState::new();
for _ in 0..3 {
compute_pid_score(
&PidInput::new(0.0, 0.3, 0.0, 0.0, 0.0, 0.0, false, 0, 0),
&config,
&mut state_clean,
);
compute_pid_score(
&PidInput::new(0.0, 0.3, 0.0, 0.0, 0.0, 0.0, false, 0, 0),
&config,
&mut state_decay,
);
}
let risk_clean = compute_pid_score(
&PidInput::new(0.0, 0.3, 0.0, 0.0, 0.0, 0.0, false, 0, 0),
&config,
&mut state_clean,
);
let risk_decay = compute_pid_score(
&PidInput::new(0.0, 0.3, 0.0, 0.0, 0.0, 0.0, false, FLAG_DECAYING, 0),
&config,
&mut state_decay,
);
assert!(
risk_decay > risk_clean,
"FLAG_DECAYING must boost ki_fast by 1.3: clean={}, decay={}",
risk_clean,
risk_decay
);
}
#[test]
fn sidechain_low_confidence_boosts_kf() {
let config = PidConfig::default();
let mut state_clean = PidState::new();
let mut state_lc = PidState::new();
let risk_clean = compute_pid_score(
&PidInput::new(0.0, 0.0, 0.0, 0.0, 0.0, 0.5, false, 0, 0),
&config,
&mut state_clean,
);
let risk_lc = compute_pid_score(
&PidInput::new(0.0, 0.0, 0.0, 0.0, 0.0, 0.5, false, FLAG_LOW_CONFIDENCE, 0),
&config,
&mut state_lc,
);
assert!(
risk_lc > risk_clean,
"FLAG_LOW_CONFIDENCE must boost kf by 1.5: clean={}, lc={}",
risk_clean,
risk_lc
);
}
#[test]
fn sidechain_anomaly_wins_over_drifting() {
let config = PidConfig::default();
let mut state_anomaly = PidState::new();
let mut state_drift = PidState::new();
let mut state_both = PidState::new();
let risk_anomaly = compute_pid_score(
&PidInput::new(0.0, 0.0, 0.0, 0.0, 8000.0, 1.0, false, FLAG_ANOMALY, 0),
&config,
&mut state_anomaly,
);
let risk_drift = compute_pid_score(
&PidInput::new(0.0, 0.0, 0.0, 0.0, 8000.0, 1.0, false, FLAG_DRIFTING, 0),
&config,
&mut state_drift,
);
let risk_both = compute_pid_score(
&PidInput::new(
0.0,
0.0,
0.0,
0.0,
8000.0,
1.0,
false,
FLAG_ANOMALY | FLAG_DRIFTING,
0,
),
&config,
&mut state_both,
);
assert!(
(risk_anomaly - risk_both).abs() < 0.001,
"FLAG_ANOMALY+DRIFTING must equal FLAG_ANOMALY alone (anomaly wins): anomaly={}, both={}",
risk_anomaly,
risk_both
);
assert!(
risk_anomaly > risk_drift,
"FLAG_ANOMALY (×1.5 Kd) must be > FLAG_DRIFTING (×1.2 Kd): anomaly={}, drift={}",
risk_anomaly,
risk_drift
);
}
#[test]
fn pid_config_rejects_integrator_decay_at_0_899() {
let config = PidConfig {
integrator_decay: 0.899,
..PidConfig::default()
};
assert!(
config.validate().is_err(),
"integrator_decay=0.899 must be rejected (<= 0.899)"
);
}
#[test]
fn pid_config_accepts_integrator_decay_at_0_9() {
let config = PidConfig {
integrator_decay: 0.9,
..PidConfig::default()
};
assert!(
config.validate().is_ok(),
"integrator_decay=0.9 must be valid (> 0.899)"
);
}
}