use wasm4pm::reinforcement::QLearning;
use wasm4pm::self_healing::{
advance_clock, reset_clock, CircuitBreaker, CircuitBreakerConfig, CircuitState, CLOCK_LOCK,
};
use wasm4pm::spc::{check_western_electric_rules, ChartData, SpecialCause, TrendDirection};
use wasm4pm::RlAction;
use wasm4pm::RlState;
fn health_state(health_level: u8) -> RlState {
wasm4pm::create_rl_state(health_level, 0, 0, 0, 0, 0, 0, 0)
}
fn greedy_q_agent() -> QLearning<RlState, RlAction> {
QLearning::with_hyperparams(0.1, 0.99, 0.0)
}
fn spc_point(timestamp: &str, value: f64, cl: f64, sigma: f64) -> ChartData {
ChartData {
timestamp: timestamp.to_string(),
value,
ucl: cl + 3.0 * sigma,
cl,
lcl: f64::max(cl - 3.0 * sigma, 0.0),
subgroup_data: None,
}
}
#[test]
fn test_mutation_1_same_state_bellman_detected() {
let agent = greedy_q_agent();
let state = health_state(2); let next_state = health_state(0); let action = RlAction::Continue;
for _ in 0..10 {
agent.update(&next_state, &RlAction::Scale, 1.0, &next_state, false);
}
let q_next = agent.get_q_value(&next_state, &RlAction::Scale);
assert!(
q_next > 0.5,
"Sanity: Q(next_state, Scale) should be high after 10 positive updates, got {}",
q_next
);
let q_before = agent.get_q_value(&state, &action);
assert_eq!(
q_before, 0.0,
"Sanity: Q(state, action) should be 0.0 for unvisited state"
);
agent.update(&state, &action, 0.0, &next_state, false);
let q_after = agent.get_q_value(&state, &action);
assert!(
q_after > q_before,
"MUTATION 1 DETECTION: Q(state, action) must increase after zero-reward update \
with valuable next_state. Before={:.6}, After={:.6}. \
If Q did not change, the Bellman update is using state instead of next_state \
(the same-state bug). The existing test_bellman_uses_next_state_not_current_state \
and test_different_next_states_produce_different_q_updates in \
bellman_correctness_tests.rs would catch this.",
q_before,
q_after
);
}
#[test]
fn test_mutation_2_spc_historical_vs_oneshot_detected() {
let cl = 5.0;
let sigma = 0.5;
let _ucl = cl + 3.0 * sigma;
let data: Vec<ChartData> = (0..20)
.map(|i| {
let value = 5.0 + (i as f64) * 0.05; spc_point(&format!("t{}", i), value, cl, sigma)
})
.collect();
for point in &data {
assert!(
point.value >= point.lcl && point.value <= point.ucl,
"Sanity: all values must be within control limits for one-shot to pass. \
Got value={} (lcl={}, ucl={})",
point.value,
point.lcl,
point.ucl
);
}
let alerts = check_western_electric_rules(&data);
assert!(
!alerts
.iter()
.any(|a| matches!(a, SpecialCause::OutOfControl { .. })),
"Sanity: Rule 1 must NOT fire when all values are within control limits"
);
let trend_alert = alerts
.iter()
.find(|a| matches!(a, SpecialCause::Trend { .. }));
assert!(
trend_alert.is_some(),
"MUTATION 2 DETECTION: Trend rule must fire on gradual drift within control limits. \
All 20 values are within [3.5, 6.5] but 6+ are consecutively increasing. \
If no Trend alert fires, SPC is doing one-shot evaluation only (the bug). \
The existing test_gradual_drift_detected_by_trend_rule in \
behavioral_drift_tests.rs would catch this. Got alerts: {:?}",
alerts
);
if let Some(SpecialCause::Trend { direction, .. }) = trend_alert {
assert_eq!(
*direction,
TrendDirection::Increasing,
"Trend direction must be Increasing for upward drift"
);
}
}
#[test]
fn test_mutation_3_event_count_not_string_length_detected() {
let features_low = [0.05f32, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5];
let features_high = [0.80f32, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5];
let state_low = RlState::from_features(&features_low, 0, 0.0);
let state_high = RlState::from_features(&features_high, 0, 0.0);
assert_ne!(
state_low.event_rate_q, state_high.event_rate_q,
"MUTATION 3 DETECTION: event_rate_q must differ when features[0] differs. \
features[0]=0.05 -> event_rate_q={}, features[0]=0.80 -> event_rate_q={}. \
If both are the same, the quantization is not using the feature value \
(possibly using string length instead). The existing \
test_quantization_event_rate_is_monotonic in \
feature_normalization_tests.rs would catch this.",
state_low.event_rate_q, state_high.event_rate_q
);
assert!(
state_high.event_rate_q > state_low.event_rate_q,
"MUTATION 3 DETECTION: Higher event rate should produce higher event_rate_q. \
features[0]=0.80 -> {}, features[0]=0.05 -> {}",
state_high.event_rate_q,
state_low.event_rate_q
);
assert_eq!(
state_low.event_rate_q, 0,
"500 events (features[0]=0.05) should quantize to bucket 0 (0..=500)"
);
assert_eq!(
state_high.event_rate_q, 7,
"8000 events (features[0]=0.80) should quantize to bucket 7 (7501+)"
);
let features_mid = [0.15f32, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5];
let state_mid = RlState::from_features(&features_mid, 0, 0.0);
assert_eq!(
state_mid.event_rate_q, 2,
"1500 events (features[0]=0.15) should quantize to bucket 2 (1001..=2000)"
);
assert!(
state_low.event_rate_q < state_mid.event_rate_q
&& state_mid.event_rate_q < state_high.event_rate_q,
"MUTATION 3 DETECTION: Monotonicity must hold: low({}) < mid({}) < high({})",
state_low.event_rate_q,
state_mid.event_rate_q,
state_high.event_rate_q
);
}
#[test]
fn test_mutation_4_circuit_breaker_step_counter_detected() {
let _clock_guard = CLOCK_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
reset_clock();
let open_timeout_ms = 1u64;
let config = CircuitBreakerConfig {
failure_threshold: 2,
success_threshold: 2,
open_timeout_ms,
half_open_timeout_ms: 30_000,
};
let mut breaker = CircuitBreaker::with_config(config).unwrap();
breaker.record_failure();
assert_eq!(
breaker.state(),
CircuitState::Closed,
"Sanity: should be Closed after 1 failure"
);
breaker.record_failure();
assert_eq!(
breaker.state(),
CircuitState::Open,
"Sanity: should be Open after 2 failures (threshold=2)"
);
assert!(
!breaker.allow_request(),
"Sanity: allow_request should return false immediately after Open"
);
assert_eq!(
breaker.state(),
CircuitState::Open,
"Sanity: state should still be Open when timeout not elapsed"
);
advance_clock(open_timeout_ms + 1);
assert!(
breaker.allow_request(),
"MUTATION 4 DETECTION: allow_request must return true after timeout elapsed. \
If this returns false, the step counter is not advancing (always 0), \
and the circuit breaker can never recover from Open state. \
The existing test_open_to_half_open_after_timeout and \
test_recovery_timeout_is_configurable in \
circuit_breaker_state_machine_tests.rs would catch this."
);
assert_eq!(
breaker.state(),
CircuitState::HalfOpen,
"MUTATION 4 DETECTION: State must be HalfOpen after timeout. \
If still Open, the step counter is not advancing. Got: {:?}",
breaker.state()
);
}
use wasm4pm::rl_orchestrator::compute_reward;
#[test]
fn test_h1_plus_to_minus_operator_mutation_detected() {
let reward_improve = compute_reward(3, 1, 0, true, true, false, 0); let reward_stable = compute_reward(2, 2, 0, true, true, false, 0);
assert!(
reward_improve > reward_stable,
"H1 MUTATION DETECTION: Health improvement reward ({:.4}) must be > stability reward ({:.4}). \
If improvement reward is negative, the + operator was mutated to -. \
Expected: reward_improve > reward_stable (improvement bonus +1.0 > stability +0.2).",
reward_improve,
reward_stable
);
assert!(
(reward_improve - 1.1).abs() < 1e-5,
"H1 MUTATION DETECTION: Health improvement reward should be exactly +1.1 \
(+1.0 improvement + 0.1 guard/circuit bonus). Got {:.6}",
reward_improve
);
}
#[test]
fn test_h2_spc_rule2_fires_at_9_not_8_detected() {
use wasm4pm::spc::{check_western_electric_rules, ChartData, SpecialCause};
let make_above = |t: &str| -> ChartData {
ChartData {
timestamp: t.to_string(),
value: 5.5, ucl: 8.0,
cl: 5.0,
lcl: 2.0,
subgroup_data: None,
}
};
let data_8: Vec<ChartData> = (0..8).map(|i| make_above(&format!("t{}", i))).collect();
let alerts_8 = check_western_electric_rules(&data_8);
let has_shift_8 = alerts_8
.iter()
.any(|a| matches!(a, SpecialCause::Shift { .. }));
assert!(
!has_shift_8,
"H2 MUTATION DETECTION: Rule 2 (Shift) must NOT fire with only 8 points above CL. \
If it fires, the boundary check uses >= 8 instead of >= 9. Got alerts: {:?}",
alerts_8
);
let data_9: Vec<ChartData> = (0..9).map(|i| make_above(&format!("t{}", i))).collect();
let alerts_9 = check_western_electric_rules(&data_9);
let has_shift_9 = alerts_9
.iter()
.any(|a| matches!(a, SpecialCause::Shift { .. }));
assert!(
has_shift_9,
"H2 MUTATION DETECTION: Rule 2 (Shift) MUST fire with exactly 9 consecutive \
points above CL. If it does not fire, the boundary is wrong. Got alerts: {:?}",
alerts_9
);
}
#[test]
fn test_h3_conditional_negation_guard_circuit_bonus_detected() {
let reward_both_pass = compute_reward(2, 2, 0, true, true, false, 0);
let reward_circ_fail = compute_reward(2, 2, 0, true, false, false, 0);
let reward_guard_fail = compute_reward(2, 2, 0, false, true, false, 0);
assert!(
reward_both_pass > reward_circ_fail,
"H3 MUTATION DETECTION: guard=true+circuit=true ({:.4}) must give higher reward \
than guard=true+circuit=false ({:.4}). If inverted, the condition was negated.",
reward_both_pass,
reward_circ_fail
);
assert!(
reward_both_pass > reward_guard_fail,
"H3 MUTATION DETECTION: guard=true+circuit=true ({:.4}) must give higher reward \
than guard=false+circuit=true ({:.4}). If inverted, the condition was negated.",
reward_both_pass,
reward_guard_fail
);
assert!(
(reward_both_pass - 0.3).abs() < 1e-5,
"H3 MUTATION DETECTION: guard=true+circuit=true reward should be exactly +0.3 \
(+0.2 stable + 0.1 bonus). Got {:.6}",
reward_both_pass
);
assert!(
(reward_circ_fail - (-0.3)).abs() < 1e-5,
"H3 MUTATION DETECTION: guard=true+circuit=false reward should be exactly -0.3 \
(+0.2 stable - 0.5 penalty). Got {:.6}",
reward_circ_fail
);
}