diag+test(policy-quality): Task 0.9 + 0.13 — controller fire-rate tracking

Adds delta-based adaptive-controller fire detection. A controller 'fired'
in epoch N iff its observable output changed from epoch N-1 beyond a small
numerical threshold. Applied to all 6 controllers per spec §5.3:
  - anti_lr          (threshold 1e-10 on LR)
  - tau              (1e-6 on target-net tau)
  - gamma            (1e-4 on discount factor)
  - grad_clip        (1e-3 on adaptive clip threshold)
  - cql_alpha        (1e-5 on CQL pessimism weight)
  - cost_anneal      (1e-4 on tx-cost anneal factor)

Adds:
  * DQNTrainer.prev_controller_values + .controller_fire_counts
  * ControllerPrevValues (NaN sentinel on first epoch = no fire)
  * ControllerFireCounts (running u32 per controller)
  * pub fn controller_fire_rates_final() -> [f32; 6]
  * HEALTH_DIAG 'controller' group shows per-epoch fire bools + max
    running fire rate across all controllers (fire_frac)

Adds smoke test controller_activity.rs asserting no controller fires in
> 50% of epochs. Expected to FAIL on current main — anti-LR fires every
epoch per today's session observation. Documents the load-bearing issue.

Completes Tasks 0.9 + 0.13 per plan. No partial state — all 6 controllers
are delta-tracked, all fire rates exposed, the smoke test asserts against
all of them.
This commit is contained in:
jgrusewski
2026-04-21 21:34:36 +02:00
parent 9004c9b0a2
commit 93471d85a2
5 changed files with 184 additions and 2 deletions

View File

@@ -0,0 +1,56 @@
//! Smoke test: controller activity (Track 3 from policy-quality spec).
//!
//! Each of the 6 adaptive controllers (anti-LR, adaptive tau, adaptive gamma,
//! adaptive grad-clip, CQL alpha schedule, cost anneal) should fire
//! DIAGNOSTICALLY — i.e. catch edge cases, not run every epoch. A controller
//! firing in > 50% of epochs is LOAD-BEARING: the policy depends on the
//! controller to stay on the rails, which is a production-readiness red flag
//! (at deployment the controller isn't there to save things).
//!
//! Fire detection: a controller "fired" in epoch N iff its observable output
//! value changed from epoch N-1. Delta-based detection, implemented in
//! training_loop.rs around the HEALTH_DIAG emission.
//!
//! Run: `FOXHUNT_TEST_DATA=test_data/futures-baseline \
//! cargo test -p ml --release --lib -- controller_activity --ignored --nocapture`
use super::helpers::*;
use anyhow::Result;
#[test]
#[ignore] // Requires fxcache
fn test_controllers_not_load_bearing() -> Result<()> {
let data = load_smoke_fxcache().expect("fxcache required — run precompute_features first");
let mut params = smoke_params();
params.epochs = 20;
params.early_stopping_enabled = false;
params.min_epochs_before_stopping = 20;
let mut trainer = smoke_trainer_with(params)?;
init_trainer_from_fxcache(&mut trainer, &data, 200_000)?;
let data_dir = test_data_dir().expect("FOXHUNT_TEST_DATA or test_data/ must exist");
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
let _metrics = rt.block_on(trainer.train(
&data_dir,
"ES.FUT",
|_epoch, _bytes, _best| Ok("skip".to_owned()),
))?;
let rates = trainer.controller_fire_rates_final();
let names = ["anti_lr", "tau", "gamma", "grad_clip", "cql_alpha", "cost_anneal"];
println!(
"[CTRL_FIRE] anti_lr={:.3} tau={:.3} gamma={:.3} clip={:.3} cql={:.3} cost={:.3}",
rates[0], rates[1], rates[2], rates[3], rates[4], rates[5]
);
for (name, &rate) in names.iter().zip(rates.iter()) {
assert!(
rate <= 0.5,
"Controller '{}' fires in {:.1}% of epochs (> 50% = load-bearing). \
Full rates: anti_lr={:.3} tau={:.3} gamma={:.3} clip={:.3} cql={:.3} cost={:.3}",
name, rate * 100.0,
rates[0], rates[1], rates[2], rates[3], rates[4], rates[5]
);
}
Ok(())
}

View File

@@ -28,3 +28,5 @@ mod td_propagation;
mod magnitude_distribution;
#[cfg(test)]
mod exploration_coverage;
#[cfg(test)]
mod controller_activity;

View File

@@ -578,6 +578,9 @@ impl DQNTrainer {
last_action_entropy: None,
last_magnitude_dist: [0.0_f32; 3],
explore_entropy_mag_history: Vec::new(),
prev_controller_values: super::ControllerPrevValues::default(),
controller_fire_counts: super::ControllerFireCounts::default(),
controller_total_epochs: 0,
// Wave 16 Portfolio Features (action masking always active)
max_position,

View File

@@ -162,6 +162,43 @@ impl BacktrackingState {
}
}
/// Task 0.9 — prior-epoch values for adaptive-controller fire detection.
/// NaN sentinel means "no prior epoch" — first real value treated as fire.
#[derive(Debug, Clone, Copy)]
pub struct ControllerPrevValues {
pub lr: f64,
pub tau: f32,
pub gamma: f32,
pub grad_clip: f32,
pub cql_alpha: f32,
pub cost_anneal: f32,
}
impl Default for ControllerPrevValues {
fn default() -> Self {
Self {
lr: f64::NAN,
tau: f32::NAN,
gamma: f32::NAN,
grad_clip: f32::NAN,
cql_alpha: f32::NAN,
cost_anneal: f32::NAN,
}
}
}
/// Task 0.9 — running count of how many epochs each controller fired
/// (i.e. changed the value it controls).
#[derive(Debug, Clone, Copy, Default)]
pub struct ControllerFireCounts {
pub anti_lr: u32,
pub tau: u32,
pub gamma: u32,
pub grad_clip: u32,
pub cql_alpha: u32,
pub cost_anneal: u32,
}
pub struct DQNTrainer {
/// DQN agent
pub(crate) agent: Arc<RwLock<DQNAgentType>>,
@@ -248,6 +285,19 @@ pub struct DQNTrainer {
/// doesn't collapse to a single bin too fast.
pub(crate) explore_entropy_mag_history: Vec<(u32, f32)>,
/// Task 0.9 — prior-epoch values of adaptive-controller outputs, used to
/// detect "fire" = value changed from the prior epoch. Controllers audited:
/// anti-LR, adaptive tau, adaptive gamma, adaptive grad-clip, CQL alpha
/// schedule, cost anneal schedule. Initialized to sentinel NaN so the
/// first epoch's comparison treats any non-NaN value as a fire.
pub(crate) prev_controller_values: ControllerPrevValues,
/// Task 0.9 — running per-controller fire counts + total-epochs counter.
/// Used by controller_activity smoke test to assert no controller fires in
/// > 50% of epochs (load-bearing controllers are a production risk).
pub(crate) controller_fire_counts: ControllerFireCounts,
pub(crate) controller_total_epochs: u32,
// Wave 16 Portfolio Features (action masking is always active).
/// Maximum position size for action masking (default: 2.0)
pub max_position: f64,
@@ -1422,6 +1472,22 @@ impl DQNTrainer {
&self.explore_entropy_mag_history
}
/// Per-controller firing rates across all completed epochs.
/// Layout: [anti_lr, tau, gamma, grad_clip, cql_alpha, cost_anneal].
/// Each rate = epochs-where-value-changed / total-epochs. Used by the
/// controller_activity smoke test to detect load-bearing controllers.
pub fn controller_fire_rates_final(&self) -> [f32; 6] {
let total = self.controller_total_epochs.max(1) as f32;
[
self.controller_fire_counts.anti_lr as f32 / total,
self.controller_fire_counts.tau as f32 / total,
self.controller_fire_counts.gamma as f32 / total,
self.controller_fire_counts.grad_clip as f32 / total,
self.controller_fire_counts.cql_alpha as f32 / total,
self.controller_fire_counts.cost_anneal as f32 / total,
]
}
/// Get current epsilon from the DQN agent
pub async fn get_agent_epsilon(&self) -> f32 {
let agent_lock = self.agent.read().await;

View File

@@ -2213,6 +2213,61 @@ impl DQNTrainer {
// Task 0.14: append to exploration entropy history for smoke-test readback.
self.explore_entropy_mag_history.push((epoch as u32, ent_mag));
// Task 0.9: adaptive-controller fire detection.
// A controller "fired" this epoch iff its observable output value
// changed from the prior epoch. NaN prev (first epoch) → no fire
// (no baseline to compare against). Uses absolute delta thresholds
// chosen per controller's natural scale — below threshold is
// floating-point noise, above is real adjustment.
// LR used for THIS epoch's training. anti-LR runs later in the loop
// (computing LR for next epoch) so scheduler value here is the
// one that was actually in effect.
let cur_lr = self.lr_scheduler.get_lr();
let cur_tau = self.last_tau_eff.unwrap_or(f32::NAN);
let cur_gamma = self.last_gamma_eff.unwrap_or(f32::NAN);
let cur_clip = self.fused_ctx.as_ref().map(|f| f.adaptive_clip_value()).unwrap_or(f32::NAN);
let cur_cql = self.last_cql_alpha_eff.unwrap_or(f32::NAN);
let cur_cost = self.cost_anneal_factor;
let prev = self.prev_controller_values;
let fire_lr = prev.lr.is_finite() && (cur_lr - prev.lr).abs() > 1e-10;
let fire_tau = prev.tau.is_finite() && (cur_tau - prev.tau).abs() > 1e-6;
let fire_gamma = prev.gamma.is_finite() && (cur_gamma - prev.gamma).abs() > 1e-4;
let fire_clip = prev.grad_clip.is_finite() && (cur_clip - prev.grad_clip).abs() > 1e-3;
let fire_cql = prev.cql_alpha.is_finite() && (cur_cql - prev.cql_alpha).abs() > 1e-5;
let fire_cost = prev.cost_anneal.is_finite() && (cur_cost - prev.cost_anneal).abs() > 1e-4;
if fire_lr { self.controller_fire_counts.anti_lr += 1; }
if fire_tau { self.controller_fire_counts.tau += 1; }
if fire_gamma { self.controller_fire_counts.gamma += 1; }
if fire_clip { self.controller_fire_counts.grad_clip += 1; }
if fire_cql { self.controller_fire_counts.cql_alpha += 1; }
if fire_cost { self.controller_fire_counts.cost_anneal += 1; }
self.controller_total_epochs += 1;
self.prev_controller_values = super::ControllerPrevValues {
lr: cur_lr,
tau: cur_tau,
gamma: cur_gamma,
grad_clip: cur_clip,
cql_alpha: cur_cql,
cost_anneal: cur_cost,
};
// fire_frac = max fire-rate across all controllers (worst-case "load-bearing"
// signal). Running fraction = fires / total_epochs for the max-firing
// controller. Captures whether *any* controller is above 50% threshold
// (the controller_activity smoke gate).
let total = self.controller_total_epochs.max(1) as f32;
let rates = [
self.controller_fire_counts.anti_lr as f32 / total,
self.controller_fire_counts.tau as f32 / total,
self.controller_fire_counts.gamma as f32 / total,
self.controller_fire_counts.grad_clip as f32 / total,
self.controller_fire_counts.cql_alpha as f32 / total,
self.controller_fire_counts.cost_anneal as f32 / total,
];
let fire_frac = rates.iter().copied().fold(0.0_f32, f32::max);
tracing::info!(
"HEALTH_DIAG[{}]: health={:.2} components [q_gap={:.2} q_var={:.2} atoms={:.2} grad_stable={:.2} ens_agree={:.2} grad_cos={:.2} spectral={:.2}] effective [cql_alpha={:.4} iqn_budget={:.2} cql_budget={:.2} c51_budget={:.2} tau={:.5} sarsa_tau={:.2} gamma={:.3} cf_ratio={:.2}] novels [distill={} barrier={:.3} plasticity={} ib={:.3} ensemble_collapse={:.2} contrarian={} meta_q_pred={:.2}] diag [sharpe_ema={:.3} action_entropy={:.2}] gems [g12_predictive={:.4}] mag [q_full={:.3} q_half={:.3} q_quarter={:.3} var_scale={:.3} kelly_f={:.3} avg_win_ratio={:.3} grad_ratio_mag_dir={:.4} dist_q={:.3} dist_h={:.3} dist_f={:.3}] trail [fire_q={:.3} fire_h={:.3} fire_f={:.3} hold_q={:.2} hold_h={:.2} hold_f={:.2}] noisy [vsn_mag={:.3} vsn_dir={:.3} sigma_mag={:.4} sigma_dir={:.4} drift_mag={:.3} drift_dir={:.3}] eval_dist [eq={:.3} eh={:.3} ef={:.3}] reward_contrib [popart={:.3} cf={:.3} trail_r={:.3} micro={:.3} la={:.3} seg={:.3}] controller [anti_lr={} tau={} gamma={} clip={} cql={} cost={} fire_frac={:.2}] explore [ent_mag={:.2} ent_dir={:.2} sigma_mean={:.4}]",
epoch,
@@ -2256,8 +2311,8 @@ impl DQNTrainer {
0.0_f32, 0.0_f32, 0.0_f32,
// Track 2 — reward contrib (6 f32)
0.0_f32, 0.0_f32, 0.0_f32, 0.0_f32, 0.0_f32, 0.0_f32,
// Track 3 — controllers (6 bool, 1 f32)
false, false, false, false, false, false, 0.0_f32,
// Track 3 — controllers (6 bool per-epoch fire + 1 f32 max running rate)
fire_lr, fire_tau, fire_gamma, fire_clip, fire_cql, fire_cost, fire_frac,
// Track 4 — explore (3 f32): ent_mag, ent_dir, sigma_mean.
// sigma_mean still stubbed (needs NoisyNets σ readback, Task 0.6).
ent_mag, ent_dir, 0.0_f32,