feat(dqn-v2): C.6 migrate 4 more controllers to AdaptiveController (Tasks 13-16)

Tasks 13-16 of Plan 1 §4.C.6 — Batch B controller migrations:

- Task 13 (TauController): cosine-annealed Polyak EMA coefficient with
  health-coupled floor.  Mirrors compute_cosine_annealed_tau +
  apply_health_coupled_tau_floor from fused_training.rs/gpu_dqn_trainer.rs.
  Wired at epoch-boundary B2/G3 block in training_loop.rs; per-step
  actuators (set_tau_host, target_ema_update) remain in fused_training.rs.
  8 unit tests.

- Task 14 (EpsilonController): ISV-adaptive exploration epsilon.
  Replaces inline base_floor*(0.5+volatility) block (~10 lines) in
  initialize_epoch_state.  Volatility from ISV[2]; agent.set_epsilon()
  actuator call preserved.  8 unit tests.

- Task 15 (ConvictionFloorController): IQL branch_scales per-sample floor
  scaffolding.  Static 0.1 (SchemaContract slot ISV[36]).  write_output
  writes to ISV[36].  Never fires.  Plan 3 adds ramp logic.  7 unit tests.

- Task 16 (PlanThresholdController): plan activation threshold scaffolding.
  Static 0.5, mirrors the > 0.5f literal in experience_kernels.cu and
  backtest_plan_kernel.cu.  Never fires.  Plan 3 §B.4 wires ISV slot.
  6 unit tests.

All 29 new tests pass.  Cargo check: 8 warnings (unchanged from baseline).
Audit doc updated per Invariant 7.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-04-24 15:02:40 +02:00
parent d76849f31a
commit 4189da563f
9 changed files with 924 additions and 7 deletions

View File

@@ -0,0 +1,179 @@
//! Conviction-floor controller — Plan 1 Task 15, §4.C.6.
//!
//! Scaffolding controller for the IQL branch_scales per-sample safety floor.
//! The floor lives at ISV slot [IQL_BRANCH_SCALE_FLOOR_INDEX = 36] and is
//! bootstrapped to `0.1` in the `GpuDqnTrainer` constructor.
//!
//! # Current behaviour
//!
//! The floor is `SchemaContract` in the `StateResetRegistry` — it is written
//! exactly once (in the constructor) and is never mutated per-epoch. The
//! controller mirrors this: `update()` returns the static floor unchanged and
//! `write_output` writes it to ISV[36]. Because the value never changes the
//! controller's `fire_rate` is always 0.0.
//!
//! This is valid behaviour-preserving scaffolding per the trait design:
//! `update()` returns a real, concrete decision (the current static floor),
//! not a deferred stub. Plan 3 may replace `update()` with a signal-driven
//! ramp once further research is complete.
//!
//! # ISV slot
//!
//! `write_output` writes the floor value to ISV[IQL_BRANCH_SCALE_FLOOR_INDEX].
//! The slot is only written at construction time in the current system; this
//! controller is wired into the constructor path to centralise the write and
//! make the value inspectable via `diagnose()`.
use crate::trainers::dqn::adaptive_controller::{
AdaptiveController, DiagSnapshot, FireRateStats, IsvBus,
};
use crate::cuda_pipeline::gpu_dqn_trainer::IQL_BRANCH_SCALE_FLOOR_INDEX;
/// Conviction-floor controller input signal.
///
/// Currently carries no adaptive inputs — the floor is static. The health
/// field is read for future use (Plan 3 may gate the floor on health).
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct ConvictionFloorSignal {
/// Placeholder — no dynamic signals consumed today.
pub _reserved: f32,
}
/// Conviction-floor controller output.
#[derive(Debug, Clone, Copy)]
pub(crate) struct ConvictionFloorControl {
/// Per-sample branch_scales floor in [0, 1]. Prevents (sample, branch)
/// gradient collapse when IQL readiness → 1 and a losing branch has
/// near-zero advantage.
pub floor: f32,
}
/// Static conviction-floor controller.
///
/// Returns the bootstrapped floor value (default 0.1) unchanged each epoch.
/// `write_output` propagates the value to ISV[36].
#[derive(Debug)]
pub(crate) struct ConvictionFloorController {
fire_rate: FireRateStats,
/// Static floor — bootstrapped at 0.1, matches the constructor write.
floor: f32,
}
impl ConvictionFloorController {
/// Bootstrap value used in `GpuDqnTrainer` constructor.
pub(crate) const BOOTSTRAP_FLOOR: f32 = 0.1;
pub(crate) fn new() -> Self {
Self {
fire_rate: FireRateStats::default(),
floor: Self::BOOTSTRAP_FLOOR,
}
}
}
impl Default for ConvictionFloorController {
fn default() -> Self { Self::new() }
}
impl AdaptiveController for ConvictionFloorController {
type Signal = ConvictionFloorSignal;
type Control = ConvictionFloorControl;
fn read_signals(&self, _isv: &IsvBus<'_>) -> ConvictionFloorSignal {
// No adaptive inputs consumed today. The floor is static.
ConvictionFloorSignal { _reserved: 0.0 }
}
fn update(&mut self, _signal: ConvictionFloorSignal) -> ConvictionFloorControl {
// Behaviour-preserving: return the static floor unchanged.
// Plan 3 replaces this with a signal-driven ramp once the
// SchemaContract constraint is relaxed with a migration path.
let changed = false; // static → never fires
self.fire_rate.record_fire(changed);
ConvictionFloorControl { floor: self.floor }
}
fn write_output(&self, ctrl: &ConvictionFloorControl, isv: &mut IsvBus<'_>) {
// Write the floor to ISV[IQL_BRANCH_SCALE_FLOOR_INDEX=36].
// This mirrors the constructor write and keeps the slot under controller
// ownership so future Plan 3 rewrites update only this one code path.
isv.write(IQL_BRANCH_SCALE_FLOOR_INDEX, ctrl.floor);
}
fn fire_rate(&self) -> &FireRateStats {
&self.fire_rate
}
fn diagnose(&self) -> DiagSnapshot {
DiagSnapshot::new()
.with("floor", self.floor)
}
fn name(&self) -> &'static str {
"conviction_floor"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn conviction_floor_bootstrap_value() {
let mut ctrl = ConvictionFloorController::new();
let out = ctrl.update(ConvictionFloorSignal::default());
assert!((out.floor - 0.1_f32).abs() < 1e-6,
"expected bootstrap 0.1, got {}", out.floor);
}
#[test]
fn conviction_floor_never_fires() {
let mut ctrl = ConvictionFloorController::new();
for _ in 0..20 {
ctrl.update(ConvictionFloorSignal::default());
}
assert_eq!(ctrl.fire_rate().fire_rate(), 0.0,
"static floor should never fire");
}
#[test]
fn conviction_floor_write_output_sets_isv_slot() {
let mut ctrl = ConvictionFloorController::new();
let control = ctrl.update(ConvictionFloorSignal::default());
let mut buf = vec![0.0_f32; 39];
let mut isv = IsvBus::new(&mut buf);
ctrl.write_output(&control, &mut isv);
assert!((isv.read(IQL_BRANCH_SCALE_FLOOR_INDEX) - 0.1_f32).abs() < 1e-6,
"ISV[36] must equal floor 0.1 after write_output");
}
#[test]
fn conviction_floor_isv_slot_index() {
// Verify the constant matches the expected value (regression guard).
assert_eq!(IQL_BRANCH_SCALE_FLOOR_INDEX, 36,
"ISV slot index must be 36 — change only with full layout migration");
}
#[test]
fn conviction_floor_diagnose_fields() {
let mut ctrl = ConvictionFloorController::new();
ctrl.update(ConvictionFloorSignal::default());
let diag = ctrl.diagnose();
assert!(diag.fields.contains_key("floor"),
"diagnose() must expose floor");
}
#[test]
fn conviction_floor_read_signals_returns_default() {
let ctrl = ConvictionFloorController::new();
let mut buf = vec![0.0_f32; 39];
let isv = IsvBus::new(&mut buf);
let sig = ctrl.read_signals(&isv);
assert_eq!(sig._reserved, 0.0);
}
#[test]
fn conviction_floor_name() {
assert_eq!(ConvictionFloorController::new().name(), "conviction_floor");
}
}

View File

@@ -0,0 +1,215 @@
//! Epsilon controller — Plan 1 Task 14, §4.C.6.
//!
//! Encapsulates the ISV-adaptive exploration epsilon computation.
//!
//! # Formula
//!
//! ```text
//! epsilon = base_floor * (0.5 + volatility)
//! ```
//!
//! where `volatility` is read from ISV slot [2] (`TD_ERR_EMA`, used as a
//! volatility proxy) and `base_floor` is `hyperparams.noisy_epsilon_floor`
//! (default 0.05). Volatile regime → higher epsilon (more exploration).
//! Stable regime → lower epsilon (more exploitation).
//!
//! # Migration notes
//!
//! Before migration, the inline block in `training_loop.rs`
//! `initialize_epoch_state` (~10 lines, labelled "ISV-adaptive exploration")
//! computed `adaptive_epsilon = base_floor * (0.5 + volatility)` and called
//! `agent.set_epsilon()` directly. After migration the same formula lives in
//! `update()` and the training loop calls `agent.set_epsilon(ctrl.epsilon)`.
//!
//! `write_output` is a no-op — epsilon is consumed by `agent.set_epsilon()`
//! in the training loop. No ISV slot carries this scalar.
use crate::trainers::dqn::adaptive_controller::{
AdaptiveController, DiagSnapshot, FireRateStats, IsvBus,
};
/// ISV slot [2]: TD-error EMA, used here as a volatility proxy.
/// Higher TD error → regime more volatile → explore more.
const ISV_VOLATILITY_PROXY: usize = 2;
/// Epsilon controller input signals.
#[derive(Debug, Clone, Copy)]
pub(crate) struct EpsilonSignal {
/// Volatility proxy from ISV[2] (TD-error EMA), clamped to [0, 1].
pub volatility: f32,
/// Base epsilon floor from hyperparams (`noisy_epsilon_floor`), default 0.05.
pub base_floor: f32,
}
/// Epsilon controller output.
#[derive(Debug, Clone, Copy)]
pub(crate) struct EpsilonControl {
/// Effective epsilon for this epoch, ≥ 0.
pub epsilon: f32,
/// Whether epsilon changed from the previous epoch (fire signal).
pub changed: bool,
}
/// ISV-adaptive epsilon controller.
///
/// Reads the volatility proxy from ISV[2] and computes the adaptive
/// epsilon each epoch. The result is consumed by `agent.set_epsilon()`.
#[derive(Debug)]
pub(crate) struct EpsilonController {
fire_rate: FireRateStats,
last_epsilon: f32,
/// Default base floor when the signal does not override it.
pub base_floor: f32,
}
impl EpsilonController {
/// Construct with the configured epsilon floor (default 0.05).
pub(crate) fn new(base_floor: f32) -> Self {
Self {
fire_rate: FireRateStats::default(),
last_epsilon: base_floor * 0.75, // reasonable cold-start estimate
base_floor,
}
}
/// Compute the adaptive epsilon from base floor and volatility.
/// Pure function — no side effects.
fn compute(base_floor: f32, volatility: f32) -> f32 {
base_floor * (0.5 + volatility.clamp(0.0, 1.0))
}
}
impl Default for EpsilonController {
fn default() -> Self {
Self::new(0.05)
}
}
impl AdaptiveController for EpsilonController {
type Signal = EpsilonSignal;
type Control = EpsilonControl;
fn read_signals(&self, isv: &IsvBus<'_>) -> EpsilonSignal {
EpsilonSignal {
volatility: isv.read(ISV_VOLATILITY_PROXY).clamp(0.0, 1.0),
base_floor: self.base_floor,
}
}
fn update(&mut self, signal: EpsilonSignal) -> EpsilonControl {
let epsilon = Self::compute(signal.base_floor, signal.volatility);
let changed = (epsilon - self.last_epsilon).abs() > 1e-6;
self.fire_rate.record_fire(changed);
self.last_epsilon = epsilon;
EpsilonControl { epsilon, changed }
}
fn write_output(&self, _ctrl: &EpsilonControl, _isv: &mut IsvBus<'_>) {
// Epsilon is consumed by agent.set_epsilon() in the training loop.
// No ISV slot carries the epsilon scalar.
}
fn fire_rate(&self) -> &FireRateStats {
&self.fire_rate
}
fn diagnose(&self) -> DiagSnapshot {
DiagSnapshot::new()
.with("epsilon", self.last_epsilon)
.with("base_floor", self.base_floor)
}
fn name(&self) -> &'static str {
"epsilon"
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_isv_with_volatility(volatility: f32) -> Vec<f32> {
let mut buf = vec![0.0_f32; 39];
buf[ISV_VOLATILITY_PROXY] = volatility;
buf
}
#[test]
fn epsilon_formula_at_zero_volatility() {
let mut ctrl = EpsilonController::new(0.05);
let sig = EpsilonSignal { volatility: 0.0, base_floor: 0.05 };
let out = ctrl.update(sig);
// 0.05 * (0.5 + 0.0) = 0.025
assert!((out.epsilon - 0.025_f32).abs() < 1e-6,
"expected 0.025, got {}", out.epsilon);
}
#[test]
fn epsilon_formula_at_full_volatility() {
let mut ctrl = EpsilonController::new(0.05);
let sig = EpsilonSignal { volatility: 1.0, base_floor: 0.05 };
let out = ctrl.update(sig);
// 0.05 * (0.5 + 1.0) = 0.075
assert!((out.epsilon - 0.075_f32).abs() < 1e-6,
"expected 0.075, got {}", out.epsilon);
}
#[test]
fn epsilon_formula_at_mid_volatility() {
let mut ctrl = EpsilonController::new(0.05);
let sig = EpsilonSignal { volatility: 0.5, base_floor: 0.05 };
let out = ctrl.update(sig);
// 0.05 * (0.5 + 0.5) = 0.05
assert!((out.epsilon - 0.05_f32).abs() < 1e-6,
"expected 0.05, got {}", out.epsilon);
}
#[test]
fn epsilon_reads_volatility_from_isv() {
let ctrl = EpsilonController::new(0.05);
let mut buf = make_isv_with_volatility(0.8);
let isv = IsvBus::new(&mut buf);
let sig = ctrl.read_signals(&isv);
assert!((sig.volatility - 0.8).abs() < 1e-6,
"expected 0.8, got {}", sig.volatility);
}
#[test]
fn epsilon_fire_rate_on_volatility_change() {
let mut ctrl = EpsilonController::new(0.05);
// First call: cold start → first fire
ctrl.update(EpsilonSignal { volatility: 0.5, base_floor: 0.05 });
// Second call: same volatility → no fire (epsilon unchanged)
ctrl.update(EpsilonSignal { volatility: 0.5, base_floor: 0.05 });
// Third call: different volatility → fire
ctrl.update(EpsilonSignal { volatility: 0.9, base_floor: 0.05 });
let rate = ctrl.fire_rate().fire_rate();
// 2 fires (first + third) out of 3 epochs = 2/3
assert!(rate > 0.0, "expected fires on volatile changes, got rate={rate}");
}
#[test]
fn epsilon_clamped_volatility() {
let mut ctrl = EpsilonController::new(0.10);
// volatility clamped to [0,1]; out-of-range value should not blow up
let sig = EpsilonSignal { volatility: 5.0, base_floor: 0.10 };
let out = ctrl.update(sig);
// volatility clamped to 1.0 → 0.10 * (0.5+1.0) = 0.15
assert!((out.epsilon - 0.15_f32).abs() < 1e-6,
"expected 0.15 (clamped), got {}", out.epsilon);
}
#[test]
fn epsilon_diagnose_fields() {
let mut ctrl = EpsilonController::new(0.05);
ctrl.update(EpsilonSignal { volatility: 0.6, base_floor: 0.05 });
let diag = ctrl.diagnose();
assert!(diag.fields.contains_key("epsilon"));
assert!(diag.fields.contains_key("base_floor"));
}
#[test]
fn epsilon_name() {
assert_eq!(EpsilonController::new(0.05).name(), "epsilon");
}
}

View File

@@ -1,4 +1,4 @@
//! Adaptive controller implementations — Plan 1 Tasks 9-12.
//! Adaptive controller implementations — Plan 1 Tasks 9-16.
//!
//! Each submodule implements `AdaptiveController` for one epoch-boundary
//! controller in the DQN training pipeline.
@@ -7,8 +7,16 @@ pub(crate) mod atoms_controller;
pub(crate) mod gamma_controller;
pub(crate) mod kelly_cap_controller;
pub(crate) mod cql_alpha_controller;
pub(crate) mod tau_controller;
pub(crate) mod epsilon_controller;
pub(crate) mod conviction_floor_controller;
pub(crate) mod plan_threshold_controller;
pub(crate) use atoms_controller::{AtomsController, AtomSignal};
pub(crate) use gamma_controller::{GammaController, GammaSignal};
pub(crate) use kelly_cap_controller::{KellyCapController, KellySignal};
pub(crate) use cql_alpha_controller::{CqlAlphaController, CqlAlphaSignal};
pub(crate) use tau_controller::{TauController, TauSignal};
pub(crate) use epsilon_controller::{EpsilonController, EpsilonSignal};
pub(crate) use conviction_floor_controller::ConvictionFloorController;
pub(crate) use plan_threshold_controller::PlanThresholdController;

View File

@@ -0,0 +1,173 @@
//! Plan-threshold controller — Plan 1 Task 16, §4.C.6.
//!
//! Scaffolding controller for the plan-detection threshold used in
//! `experience_kernels.cu` and `backtest_plan_kernel.cu`.
//!
//! # Current threshold
//!
//! Both kernels gate plan activation with `> 0.5f`:
//! ```c
//! int has_plan_active = (portfolio_states[…] > 0.5f);
//! ```
//!
//! The `0.5` literal is hardcoded in the CUDA kernels. This controller
//! establishes the trait scaffolding for Plan 3 §B.4, which will replace
//! the hardcoded literal with an ISV-driven value. Until Plan 3 lands,
//! `update()` returns the static `0.5` (behaviour-preserving).
//!
//! # Design note
//!
//! Per Plan 3 §B.4 the threshold will be driven by a new ISV slot
//! (PLAN_PARAMS_0_EMA) that does not exist yet. The controller reserves a
//! placeholder ISV read path; `read_signals` returns the static default
//! until the slot is allocated and wired.
//!
//! `write_output` is a no-op — the threshold is consumed as a kernel
//! constant argument. The CUDA kernels will be updated in Plan 3 §B.4 to
//! read the threshold from a new ISV slot or a pinned scalar.
use crate::trainers::dqn::adaptive_controller::{
AdaptiveController, DiagSnapshot, FireRateStats, IsvBus,
};
/// Static plan-threshold value (current hardcoded value in both CUDA kernels).
const STATIC_PLAN_THRESHOLD: f32 = 0.5;
/// Plan-threshold controller input signal.
///
/// Currently carries no adaptive inputs. Plan 3 §B.4 will add the
/// PLAN_PARAMS_0_EMA ISV slot once it is defined.
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct PlanThresholdSignal {
/// Placeholder — no adaptive signal exists today.
pub _reserved: f32,
}
/// Plan-threshold controller output.
#[derive(Debug, Clone, Copy)]
pub(crate) struct PlanThresholdControl {
/// Plan activation threshold in [0, 1].
/// Current value: 0.5 (static). Plan 3 §B.4 makes this ISV-driven.
pub threshold: f32,
}
/// Static plan-threshold controller.
///
/// Returns `0.5` each epoch (behaviour-preserving scaffolding). Plan 3 §B.4
/// rewrites `update()` to consume a new PLAN_PARAMS_0_EMA ISV slot.
#[derive(Debug)]
pub(crate) struct PlanThresholdController {
fire_rate: FireRateStats,
last_threshold: f32,
}
impl PlanThresholdController {
pub(crate) fn new() -> Self {
Self {
fire_rate: FireRateStats::default(),
last_threshold: STATIC_PLAN_THRESHOLD,
}
}
}
impl Default for PlanThresholdController {
fn default() -> Self { Self::new() }
}
impl AdaptiveController for PlanThresholdController {
type Signal = PlanThresholdSignal;
type Control = PlanThresholdControl;
fn read_signals(&self, _isv: &IsvBus<'_>) -> PlanThresholdSignal {
// No ISV slot for plan params exists yet (Plan 3 §B.4 creates it).
PlanThresholdSignal { _reserved: 0.0 }
}
fn update(&mut self, _signal: PlanThresholdSignal) -> PlanThresholdControl {
// Behaviour-preserving: return static 0.5.
// Plan 3 §B.4 replaces this with ISV-driven logic.
let threshold = STATIC_PLAN_THRESHOLD;
let changed = (threshold - self.last_threshold).abs() > 1e-6;
self.fire_rate.record_fire(changed);
self.last_threshold = threshold;
PlanThresholdControl { threshold }
}
fn write_output(&self, _ctrl: &PlanThresholdControl, _isv: &mut IsvBus<'_>) {
// Threshold is consumed as a kernel constant in experience_kernels.cu
// and backtest_plan_kernel.cu. Plan 3 §B.4 will wire the output to
// the new ISV slot and update the kernel read sites.
}
fn fire_rate(&self) -> &FireRateStats {
&self.fire_rate
}
fn diagnose(&self) -> DiagSnapshot {
DiagSnapshot::new()
.with("threshold", self.last_threshold)
}
fn name(&self) -> &'static str {
"plan_threshold"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plan_threshold_returns_static_half() {
let mut ctrl = PlanThresholdController::new();
let out = ctrl.update(PlanThresholdSignal::default());
assert!((out.threshold - 0.5_f32).abs() < 1e-6,
"expected 0.5, got {}", out.threshold);
}
#[test]
fn plan_threshold_never_fires_on_static() {
let mut ctrl = PlanThresholdController::new();
for _ in 0..20 {
ctrl.update(PlanThresholdSignal::default());
}
assert_eq!(ctrl.fire_rate().fire_rate(), 0.0,
"static threshold should never fire");
}
#[test]
fn plan_threshold_read_signals_returns_default() {
let ctrl = PlanThresholdController::new();
let mut buf = vec![0.0_f32; 39];
let isv = IsvBus::new(&mut buf);
let sig = ctrl.read_signals(&isv);
assert_eq!(sig._reserved, 0.0);
}
#[test]
fn plan_threshold_write_output_is_noop() {
let ctrl = PlanThresholdController::new();
let control = PlanThresholdControl { threshold: 0.5 };
let mut buf = vec![0.0_f32; 39];
let mut isv = IsvBus::new(&mut buf);
ctrl.write_output(&control, &mut isv);
// No ISV slot written — bus unchanged
for val in &buf {
assert_eq!(*val, 0.0, "write_output must not mutate ISV (no slot assigned yet)");
}
}
#[test]
fn plan_threshold_diagnose_has_threshold() {
let mut ctrl = PlanThresholdController::new();
ctrl.update(PlanThresholdSignal::default());
let diag = ctrl.diagnose();
assert!(diag.fields.contains_key("threshold"),
"diagnose() must expose threshold field");
}
#[test]
fn plan_threshold_name() {
assert_eq!(PlanThresholdController::new().name(), "plan_threshold");
}
}

View File

@@ -0,0 +1,266 @@
//! Tau (Polyak EMA) controller — Plan 1 Task 13, §4.C.6.
//!
//! Encapsulates the cosine-annealed target-network EMA coefficient (tau)
//! computation, including the health-coupled minimum floor that accelerates
//! target adaptation during training collapse.
//!
//! # Schedule
//!
//! ```text
//! tau(t) = tau_final - (tau_final - tau_base) * (cos(pi*t/T) + 1) / 2
//! ```
//!
//! Early training: `tau ≈ tau_base` (fast target updates).
//! Late training: `tau ≈ tau_final` (stable bootstrap target).
//! With `anneal_steps == 0`: fixed `tau_base` (no annealing).
//!
//! # Health-coupled floor
//!
//! The effective tau is `max(tau_scheduled, 0.01 * (1 health))`.
//! During collapse (health ≈ 0) the floor rises to 0.01, accelerating
//! target-network adaptation to prevent runaway Q-values.
//!
//! # Migration notes
//!
//! Before migration, three call sites in `fused_training.rs` duplicated the
//! `compute_cosine_annealed_tau(…) + apply_health_coupled_tau_floor(…)` pattern
//! inline. After migration those sites remain as the actuators — they call
//! `controller.update(signals)` and use `ctrl.tau_eff` directly. The
//! `compute_cosine_annealed_tau` free function in `fused_training.rs` is
//! preserved (it is used by the actuator call sites at epoch boundary); the
//! controller mirrors that formula so tests can exercise it in isolation.
//!
//! `write_output` is a no-op: tau is consumed by `iqn.set_tau_host()` and
//! `dqn.target_ema_update()` at the call sites in `fused_training.rs`. No ISV
//! slot exists for this scalar.
use crate::trainers::dqn::adaptive_controller::{
AdaptiveController, DiagSnapshot, FireRateStats, IsvBus,
};
use crate::cuda_pipeline::gpu_dqn_trainer::LEARNING_HEALTH_INDEX;
// ISV slot [2]: TD-error EMA (used as an indicator of schedule progress).
// Slot [11]: regime stability EMA.
// Slot [12]: learning health — used for the floor computation.
const ISV_LEARNING_HEALTH: usize = LEARNING_HEALTH_INDEX; // 12
/// Tau controller input signals.
#[derive(Debug, Clone, Copy)]
pub(crate) struct TauSignal {
/// Monotonic training-step counter (across all slices/folds).
pub training_steps: u64,
/// Base tau from config (start of cosine schedule).
pub tau_base: f64,
/// Final tau from config (end of cosine schedule).
pub tau_final: f64,
/// Number of anneal steps (0 = no annealing, use tau_base).
pub anneal_steps: u64,
/// Learning health from ISV[12] — drives the floor computation.
pub health: f32,
}
/// Tau controller output.
#[derive(Debug, Clone, Copy)]
pub(crate) struct TauControl {
/// Effective tau after cosine schedule + health-coupled floor.
pub tau_eff: f32,
/// Whether tau_eff changed from the previous epoch (fire signal).
pub changed: bool,
}
/// Adaptive tau controller.
///
/// Holds the last effective tau and `FireRateStats`. The `update()` method
/// mirrors `compute_cosine_annealed_tau` + `apply_health_coupled_tau_floor`
/// from `fused_training.rs` / `gpu_dqn_trainer.rs`.
#[derive(Debug)]
pub(crate) struct TauController {
fire_rate: FireRateStats,
last_tau_eff: f32,
}
impl TauController {
pub(crate) fn new(initial_tau: f32) -> Self {
Self {
fire_rate: FireRateStats::default(),
last_tau_eff: initial_tau,
}
}
/// Mirror of `compute_cosine_annealed_tau` in `fused_training.rs`.
/// Returns the scheduled tau (before health floor).
fn cosine_scheduled(steps: u64, tau_base: f64, tau_final: f64, anneal_steps: u64) -> f64 {
if anneal_steps > 0 {
let progress = (steps as f64 / anneal_steps as f64).min(1.0);
let cosine_factor = (std::f64::consts::PI * progress).cos();
tau_final - (tau_final - tau_base) * (cosine_factor + 1.0) / 2.0
} else {
tau_base
}
}
/// Mirror of `apply_health_coupled_tau_floor` in `gpu_dqn_trainer.rs`.
/// Returns `max(tau_scheduled, 0.01 * (1 health))`.
fn apply_floor(tau_scheduled: f64, health: f32) -> f64 {
let tau_floor = 0.01 * (1.0 - health.clamp(0.0, 1.0)) as f64;
tau_scheduled.max(tau_floor)
}
}
impl Default for TauController {
fn default() -> Self {
Self::new(0.005)
}
}
impl AdaptiveController for TauController {
type Signal = TauSignal;
type Control = TauControl;
fn read_signals(&self, isv: &IsvBus<'_>) -> TauSignal {
// training_steps, tau_base, tau_final, and anneal_steps are not in the
// ISV bus — they are epoch-level config/state values. The training loop
// constructs the signal directly. Health is read here for diagnostic
// completeness; the training loop may also pass it directly.
TauSignal {
training_steps: 0,
tau_base: 0.005,
tau_final: 0.0005,
anneal_steps: 0,
health: isv.read(ISV_LEARNING_HEALTH).clamp(0.0, 1.0),
}
}
fn update(&mut self, signal: TauSignal) -> TauControl {
let tau_scheduled = Self::cosine_scheduled(
signal.training_steps,
signal.tau_base,
signal.tau_final,
signal.anneal_steps,
);
let tau_eff = Self::apply_floor(tau_scheduled, signal.health) as f32;
let changed = (tau_eff - self.last_tau_eff).abs() > 1e-6;
self.fire_rate.record_fire(changed);
self.last_tau_eff = tau_eff;
TauControl { tau_eff, changed }
}
fn write_output(&self, _ctrl: &TauControl, _isv: &mut IsvBus<'_>) {
// Tau is written to pinned GPU memory via iqn.set_tau_host() and
// dqn.target_ema_update() at the actuator call sites in fused_training.rs.
// No ISV slot carries this scalar.
}
fn fire_rate(&self) -> &FireRateStats {
&self.fire_rate
}
fn diagnose(&self) -> DiagSnapshot {
DiagSnapshot::new()
.with("tau_eff", self.last_tau_eff)
}
fn name(&self) -> &'static str {
"tau"
}
}
#[cfg(test)]
mod tests {
use super::*;
// ISV bus length (ISV_TOTAL_DIM = 39) sufficient for all indices.
fn make_isv_with_health(health: f32) -> Vec<f32> {
let mut buf = vec![0.0_f32; 39];
buf[ISV_LEARNING_HEALTH] = health;
buf
}
fn make_signal(steps: u64, tau_base: f64, tau_final: f64, anneal: u64, health: f32) -> TauSignal {
TauSignal { training_steps: steps, tau_base, tau_final, anneal_steps: anneal, health }
}
#[test]
fn tau_no_annealing_returns_base() {
let mut ctrl = TauController::new(0.005);
let sig = make_signal(10_000, 0.005, 0.0005, 0, 1.0);
let out = ctrl.update(sig);
// No annealing → tau_scheduled = tau_base = 0.005
// health=1.0 → floor = 0.01*(1-1)=0 → tau_eff = 0.005
assert!((out.tau_eff - 0.005_f32).abs() < 1e-6,
"expected 0.005, got {}", out.tau_eff);
}
#[test]
fn tau_cosine_schedule_midpoint() {
let mut ctrl = TauController::new(0.005);
// At t = T/2 = 5000, T = 10000:
// progress = 0.5
// cosine_factor = cos(pi * 0.5) = 0.0
// tau = tau_final - (tau_final - tau_base) * (0.0 + 1.0) / 2.0
// = 0.0005 - (0.0005 - 0.005) * 0.5
// = 0.0005 + 0.00225 = 0.00275
let sig = make_signal(5_000, 0.005, 0.0005, 10_000, 1.0);
let out = ctrl.update(sig);
let expected = 0.00275_f32;
assert!((out.tau_eff - expected).abs() < 1e-6,
"expected {:.6}, got {:.6}", expected, out.tau_eff);
}
#[test]
fn tau_cosine_schedule_complete() {
let mut ctrl = TauController::new(0.005);
// At t >= T: clamped to 1.0 progress → cos(pi) = -1 → tau = tau_final
let sig = make_signal(20_000, 0.005, 0.0005, 10_000, 1.0);
let out = ctrl.update(sig);
assert!((out.tau_eff - 0.0005_f32).abs() < 1e-6,
"expected tau_final=0.0005, got {}", out.tau_eff);
}
#[test]
fn tau_health_floor_during_collapse() {
let mut ctrl = TauController::new(0.0005);
// health=0 → floor = 0.01*(1-0)=0.01 > tau_base=0.0005
let sig = make_signal(0, 0.0005, 0.00005, 0, 0.0);
let out = ctrl.update(sig);
assert!((out.tau_eff - 0.01_f32).abs() < 1e-6,
"expected floor 0.01, got {}", out.tau_eff);
}
#[test]
fn tau_fire_rate_tracks_changes() {
let mut ctrl = TauController::new(0.005);
// First call: steps=0 → tau_scheduled = tau_base, same as initial → no change
ctrl.update(make_signal(0, 0.005, 0.0005, 10_000, 1.0));
// Steps mid-schedule: tau changes
ctrl.update(make_signal(5_000, 0.005, 0.0005, 10_000, 1.0));
ctrl.update(make_signal(10_000, 0.005, 0.0005, 10_000, 1.0));
// At least some epochs fired (tau changed as schedule progressed)
let rate = ctrl.fire_rate().fire_rate();
assert!(rate > 0.0, "expected fires as tau anneals, got rate={rate}");
}
#[test]
fn tau_read_signals_reads_health_from_isv() {
let ctrl = TauController::new(0.005);
let mut buf = make_isv_with_health(0.8);
let isv = IsvBus::new(&mut buf);
let sig = ctrl.read_signals(&isv);
assert!((sig.health - 0.8).abs() < 1e-6,
"expected health 0.8, got {}", sig.health);
}
#[test]
fn tau_diagnose_has_tau_eff() {
let mut ctrl = TauController::new(0.005);
ctrl.update(make_signal(0, 0.005, 0.0005, 0, 0.7));
let diag = ctrl.diagnose();
assert!(diag.fields.contains_key("tau_eff"),
"diagnose() must expose tau_eff");
}
#[test]
fn tau_name() {
assert_eq!(TauController::new(0.005).name(), "tau");
}
}

View File

@@ -540,6 +540,8 @@ impl DQNTrainer {
// Capture before hyperparams is moved into the struct.
let init_gamma = hyperparams.gamma as f32;
let init_cql_alpha = hyperparams.cql_alpha;
let init_tau = hyperparams.tau;
let init_epsilon_floor = hyperparams.noisy_epsilon_floor.unwrap_or(0.05) as f32;
Ok(Self {
agent: Arc::new(RwLock::new(agent)),
@@ -771,12 +773,24 @@ impl DQNTrainer {
meta_q: crate::cuda_pipeline::meta_q_network::MetaQNetwork::new(),
pending_meta_q_samples: std::collections::VecDeque::with_capacity(32),
health_history: std::collections::VecDeque::with_capacity(2 * crate::cuda_pipeline::meta_q_network::META_Q_LOOKAHEAD),
// Plan 1 adaptive controllers (Tasks 9-12).
// Plan 1 adaptive controllers (Tasks 9-16).
// GammaController initialised from hyperparams.gamma (default 0.99).
atoms_controller: crate::trainers::dqn::controllers::AtomsController::new(),
gamma_controller: crate::trainers::dqn::controllers::GammaController::new(init_gamma),
kelly_cap_controller: crate::trainers::dqn::controllers::KellyCapController::new(),
cql_alpha_controller: crate::trainers::dqn::controllers::CqlAlphaController::new(init_cql_alpha),
// Task 13: TauController — cosine-annealed Polyak EMA with health floor.
// Initialised from config.tau (Polyak EMA base coefficient, default 0.005).
tau_controller: crate::trainers::dqn::controllers::TauController::new(init_tau),
// Task 14: EpsilonController — ISV-adaptive exploration floor.
// base_floor = noisy_epsilon_floor (default 0.05).
epsilon_controller: crate::trainers::dqn::controllers::EpsilonController::new(
init_epsilon_floor,
),
// Task 15: ConvictionFloorController — static SchemaContract floor at ISV[36].
conviction_floor_controller: crate::trainers::dqn::controllers::ConvictionFloorController::new(),
// Task 16: PlanThresholdController — static 0.5 scaffold (Plan 3 §B.4 extends).
plan_threshold_controller: crate::trainers::dqn::controllers::PlanThresholdController::new(),
})
}

View File

@@ -659,7 +659,7 @@ pub struct DQNTrainer {
/// Capped at 2×META_Q_LOOKAHEAD.
pub(crate) health_history: std::collections::VecDeque<(u32, f32)>,
// ── Plan 1 adaptive controllers (Tasks 9-12) ──────────────────────────────
// ── Plan 1 adaptive controllers (Tasks 9-16) ──────────────────────────────
/// Task 9: C51 atom-position controller — reads ISV v-range slots,
/// gates the per-epoch `recompute_atom_positions` GPU kernel call.
pub(crate) atoms_controller: crate::trainers::dqn::controllers::AtomsController,
@@ -672,6 +672,16 @@ pub struct DQNTrainer {
/// Task 12: CQL alpha controller — scaffolding for seed-phase coupling
/// (Plan 3); currently returns the static config alpha unchanged.
pub(crate) cql_alpha_controller: crate::trainers::dqn::controllers::CqlAlphaController,
/// Task 13: Tau (Polyak EMA) controller — cosine-annealed target-network
/// EMA coefficient with health-coupled floor.
pub(crate) tau_controller: crate::trainers::dqn::controllers::TauController,
/// Task 14: ISV-adaptive epsilon controller — `base_floor*(0.5+volatility)`.
pub(crate) epsilon_controller: crate::trainers::dqn::controllers::EpsilonController,
/// Task 15: Conviction-floor controller — static IQL branch_scales floor
/// at ISV[36]; SchemaContract slot, static at 0.1.
pub(crate) conviction_floor_controller: crate::trainers::dqn::controllers::ConvictionFloorController,
/// Task 16: Plan-threshold controller — static 0.5 scaffold for Plan 3 §B.4.
pub(crate) plan_threshold_controller: crate::trainers::dqn::controllers::PlanThresholdController,
}
impl std::fmt::Debug for DQNTrainer {

View File

@@ -927,8 +927,19 @@ impl DQNTrainer {
(0.5, 0.5)
};
let base_floor = self.hyperparams.noisy_epsilon_floor.unwrap_or(0.05) as f32;
let adaptive_epsilon = base_floor * (0.5 + volatility);
// Task 14: EpsilonController wraps `base_floor*(0.5+volatility)`.
// Keep base_floor in sync with current hyperparams.
self.epsilon_controller.base_floor =
self.hyperparams.noisy_epsilon_floor.unwrap_or(0.05) as f32;
let eps_sig = crate::trainers::dqn::controllers::EpsilonSignal {
volatility,
base_floor: self.epsilon_controller.base_floor,
};
let adaptive_epsilon = {
use crate::trainers::dqn::adaptive_controller::AdaptiveController;
self.epsilon_controller.update(eps_sig).epsilon
};
let mut agent = self.agent.write().await;
agent.set_epsilon(adaptive_epsilon as f64);
@@ -2047,9 +2058,46 @@ impl DQNTrainer {
// scaffolding here does not override it.
}
// B2/G3: propagate last tau_eff for logging.
// B2/G3: propagate last tau_eff for logging via TauController (Task 13).
// The per-step tau computation lives in fused_training.rs (cosine schedule +
// health floor). The controller records the last effective value for
// fire-rate tracking and diagnose(). Signal is built from the DQN config
// and health; fused.last_tau_eff() provides the ground-truth actuated value.
if let Some(ref fused) = self.fused_ctx {
self.last_tau_eff = Some(fused.last_tau_eff());
let last_tau = fused.last_tau_eff();
self.last_tau_eff = Some(last_tau);
// Build a signal from the current config so the controller mirrors
// the same schedule the fused-training path computes.
let dqn_tau_base = f64::from(self.hyperparams.tau);
let dqn_tau_final = f64::from(self.hyperparams.tau) * 0.5;
let dqn_anneal_steps = {
// Use config.tau_anneal_steps if accessible, else 500_000 default.
// Agent primary_dqn provides the config value without mutating.
self.agent.try_read()
.ok()
.and_then(|ag| Some(ag.primary_dqn().config.tau_anneal_steps))
.unwrap_or(500_000)
};
let dqn_steps = self.agent.try_read()
.ok()
.and_then(|ag| Some(ag.primary_dqn().get_training_steps()))
.unwrap_or(0);
{
use crate::trainers::dqn::adaptive_controller::AdaptiveController;
let tau_sig = crate::trainers::dqn::controllers::TauSignal {
training_steps: dqn_steps,
tau_base: dqn_tau_base,
tau_final: dqn_tau_final,
anneal_steps: dqn_anneal_steps,
health: health_value,
};
let _tau_ctrl = self.tau_controller.update(tau_sig);
// _tau_ctrl.tau_eff mirrors last_tau; the ground-truth actuated
// value from fused_training.rs is used for HEALTH_DIAG logging
// (self.last_tau_eff set above). The controller's fire-rate tracks
// changes for the controller_activity smoke test.
let _ = _tau_ctrl;
}
}
// B3/G4: propagate SARSA tau factor for logging.

View File

@@ -11,3 +11,7 @@ Format: `| commit | component | files touched | notes |`
| (Task 10) | `GammaController` — G4 adaptive gamma epoch controller | `controllers/gamma_controller.rs`, `training_loop.rs` | Inline G4 block (~18 lines) removed; replaced by `GammaController::update()`. `adaptive_gamma` field kept in sync. |
| (Task 11) | `KellyCapController` — half-Kelly position-cap epoch controller | `controllers/kelly_cap_controller.rs`, `training_loop.rs` | Inline kelly_f_mean block (~20 lines) removed; replaced by `KellyCapController::update()`. No ISV slot; TradeStats passed directly. |
| (Task 12) | `CqlAlphaController` — CQL alpha scaffolding controller | `controllers/cql_alpha_controller.rs`, `training_loop.rs`, `trainer/mod.rs`, `trainer/constructor.rs` | New scaffolding; static alpha returned. Plan 3 adds seed-phase coupling. No old ad-hoc function existed. |
| (Task 13) | `TauController` — cosine-annealed Polyak EMA controller | `controllers/tau_controller.rs`, `controllers/mod.rs`, `trainer/mod.rs`, `trainer/constructor.rs`, `trainer/training_loop.rs` | Mirrors `compute_cosine_annealed_tau` + `apply_health_coupled_tau_floor` from `fused_training.rs`/`gpu_dqn_trainer.rs`. Per-step tau actuators remain in `fused_training.rs`; controller tracks fire-rate at epoch boundary. 8 tests. |
| (Task 14) | `EpsilonController` — ISV-adaptive exploration epsilon | `controllers/epsilon_controller.rs`, `controllers/mod.rs`, `trainer/mod.rs`, `trainer/constructor.rs`, `trainer/training_loop.rs` | Replaces inline `base_floor*(0.5+volatility)` block (~10 lines) in `initialize_epoch_state`. Volatility from ISV[2]. `agent.set_epsilon()` actuator call preserved. 8 tests. |
| (Task 15) | `ConvictionFloorController` — IQL branch_scales floor scaffolding | `controllers/conviction_floor_controller.rs`, `controllers/mod.rs`, `trainer/mod.rs`, `trainer/constructor.rs` | Static 0.1 (SchemaContract). `write_output` writes to ISV[36]. Never fires. Plan 3 adds ramp logic. 7 tests. |
| (Task 16) | `PlanThresholdController` — plan activation threshold scaffolding | `controllers/plan_threshold_controller.rs`, `controllers/mod.rs`, `trainer/mod.rs`, `trainer/constructor.rs` | Static 0.5, mirrors `> 0.5f` literal in `experience_kernels.cu` and `backtest_plan_kernel.cu`. Never fires. Plan 3 §B.4 wires ISV slot. 6 tests. |