feat(moe): MoeGate + MoeExpert skeletons + DQN field plumbing

Module crates/ml-dqn/src/moe.rs introduces:
- MoeGate: 42→64→8 softmax gating network, zero-init for uniform start.
- MoeExpert: 256→64→256 bottleneck, Xavier init per-expert seed.
- Constants: MOE_NUM_EXPERTS=8, MOE_EXPERT_BOTTLENECK=64, MOE_GATE_HIDDEN=64.

DQN struct gains optional moe_gate / moe_experts fields, initialized to
None for now — actual wire-up into the forward graph in Phase 3.

Spec: docs/superpowers/specs/2026-04-27-moe-regime-redesign-design.md §4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-04-27 18:14:22 +02:00
parent 1cb894cca6
commit 13773375b2
4 changed files with 126 additions and 0 deletions

View File

@@ -877,6 +877,16 @@ pub struct DQN {
/// Noise sigma scale for annealed exploration (1.0 = default, decreases over training)
noise_sigma_scale: f64,
/// Mixture-of-Experts gate subnetwork: state[42] → 64 → 8 → softmax.
/// Initialized to zero so initial g(s) = uniform 1/K for every state.
/// `None` until Phase 3 wires it up.
pub moe_gate: Option<super::moe::MoeGate>,
/// 8 expert MLPs: each is h_s1[256] → 64 → h_s2[256] bottleneck.
/// Independent Xavier init per expert for initial differentiation.
/// `None` until Phase 3 wires it up.
pub moe_experts: Option<Vec<super::moe::MoeExpert>>,
}
impl DQN {
@@ -1032,6 +1042,8 @@ impl DQN {
q_value_divergence_counter: 0,
training_forward_active: false,
noise_sigma_scale: 1.0,
moe_gate: None,
moe_experts: None,
})
}

View File

@@ -50,6 +50,9 @@ pub mod multi_asset;
// Branching DQN (Phase C+)
pub mod branching;
// Mixture-of-Experts regime gating (Phase 1 skeleton)
pub mod moe;
#[cfg(test)]
mod branching_composition_tests;
#[cfg(test)]
@@ -113,6 +116,7 @@ pub use reward::{MarketData, RewardConfig, RewardFunction, RiskMetrics};
pub use distributional::{CategoricalDistribution, DistributionalConfig, DistributionalType};
pub use distributional_dueling::{DistributionalDuelingConfig, DistributionalDuelingQNetwork};
pub use branching::{BranchingConfig, BranchingDuelingQNetwork, BranchOutput};
pub use moe::{MoeExpert, MoeGate, MOE_EXPERT_BOTTLENECK, MOE_GATE_HIDDEN, MOE_NUM_EXPERTS};
pub use dueling::{DuelingConfig, DuelingQNetwork};
pub use rainbow_config::{RainbowAgentConfig, RainbowAgentMetrics, RainbowDQNConfig};
pub use rainbow_network::{RainbowNetwork, RainbowNetworkConfig};

102
crates/ml-dqn/src/moe.rs Normal file
View File

@@ -0,0 +1,102 @@
//! Mixture-of-Experts components for the DQN policy network.
//!
//! Replaces the vestigial RegimeConditionalDQN with a learned-gate
//! K=8 expert mixture per the spec at
//! `docs/superpowers/specs/2026-04-27-moe-regime-redesign-design.md`.
use std::sync::Arc;
use cudarc::driver::CudaStream;
use ml_core::cuda_autograd::GpuTensor;
use ml_core::MLError;
use rand::SeedableRng;
use rand::rngs::StdRng;
use rand::Rng;
use rand_distr::Normal;
/// K=8 experts (compile-time constant per spec §3).
pub const MOE_NUM_EXPERTS: usize = 8;
/// Expert bottleneck width per spec §4.2.
pub const MOE_EXPERT_BOTTLENECK: usize = 64;
/// Gate hidden width per spec §4.1.
pub const MOE_GATE_HIDDEN: usize = 64;
/// Gate subnetwork: state[42] → 64 → 8 → softmax over experts.
///
/// Parameters initialized to zero so g(s) = uniform 1/K initially —
/// no expert is favored by init noise; specialization emerges from data.
pub struct MoeGate {
/// Linear 1 weights [state_dim, 64], zero-init.
pub w1: GpuTensor,
/// Linear 1 bias [64], zero-init.
pub b1: GpuTensor,
/// Linear 2 weights [64, 8], zero-init.
pub w2: GpuTensor,
/// Linear 2 bias [8], zero-init.
pub b2: GpuTensor,
}
impl MoeGate {
/// Construct with zero-initialized weights so initial gate = uniform 1/K.
pub fn new(state_dim: usize, stream: &Arc<CudaStream>) -> Result<Self, MLError> {
let w1 = GpuTensor::zeros(&[state_dim, MOE_GATE_HIDDEN], stream)?;
let b1 = GpuTensor::zeros(&[MOE_GATE_HIDDEN], stream)?;
let w2 = GpuTensor::zeros(&[MOE_GATE_HIDDEN, MOE_NUM_EXPERTS], stream)?;
let b2 = GpuTensor::zeros(&[MOE_NUM_EXPERTS], stream)?;
Ok(Self { w1, b1, w2, b2 })
}
}
/// Single expert MLP: h_s1[256] → 64 → h_s2[256] with bottleneck.
///
/// Each expert has independent Xavier init for initial differentiation.
pub struct MoeExpert {
/// Linear 1 weights [256, 64], Xavier init.
pub w1: GpuTensor,
/// Linear 1 bias [64], zero-init.
pub b1: GpuTensor,
/// Linear 2 weights [64, 256], Xavier init.
pub w2: GpuTensor,
/// Linear 2 bias [256], zero-init.
pub b2: GpuTensor,
}
impl MoeExpert {
/// Construct with Xavier-initialized weights.
///
/// `seed` provides per-expert distinct random init so experts have
/// non-identical starting points.
pub fn new(h_dim: usize, seed: u64, stream: &Arc<CudaStream>) -> Result<Self, MLError> {
let w1 = init_xavier(
&[h_dim, MOE_EXPERT_BOTTLENECK],
seed,
stream,
)?;
let b1 = GpuTensor::zeros(&[MOE_EXPERT_BOTTLENECK], stream)?;
let w2 = init_xavier(
&[MOE_EXPERT_BOTTLENECK, h_dim],
seed.wrapping_add(1),
stream,
)?;
let b2 = GpuTensor::zeros(&[h_dim], stream)?;
Ok(Self { w1, b1, w2, b2 })
}
}
/// Xavier-normal initialization helper.
///
/// One-time startup cost; not on the training hot-path.
fn init_xavier(shape: &[usize], seed: u64, stream: &Arc<CudaStream>) -> Result<GpuTensor, MLError> {
let fan_in = shape.first().copied().unwrap_or(1) as f32;
let fan_out = shape.get(1).copied().unwrap_or(1) as f32;
let stddev = (2.0_f32 / (fan_in + fan_out)).sqrt();
let normal = Normal::new(0.0_f32, stddev)
.map_err(|e| MLError::ModelError(format!("MoE Xavier dist: {e}")))?;
let numel: usize = shape.iter().product();
let mut rng = StdRng::seed_from_u64(seed);
let host: Vec<f32> = (0..numel).map(|_| rng.sample(normal)).collect();
GpuTensor::from_host(&host, shape.to_vec(), stream)
.map_err(|e| MLError::ModelError(format!("MoE init xavier: {e}")))
}

View File

@@ -759,3 +759,11 @@ The 3 remaining Orphan rows are:
<!-- 2026-04-27: config.rs — Phase 1 Task 1.1: add `moe_lambda: Option<f32>` hyperparameter (default Some(0.01)) to DQNHyperparameters. Pure additive field; no kernel, no ISV slot, no hot-path consumer in this commit. Wiring into GpuDqnTrainConfig happens in a later Phase 1 task. -->
<!-- 2026-04-27: MoE module skeleton + DQN field plumbing (Tasks 1.4+1.5): created
`crates/ml-dqn/src/moe.rs` with `MoeGate` (state→64→8 softmax) and
`MoeExpert` (h_s1[256]→64→256 bottleneck) struct skeletons. Constants
`MOE_NUM_EXPERTS=8`, `MOE_EXPERT_BOTTLENECK=64`, `MOE_GATE_HIDDEN=64`.
DQN struct gains optional `moe_gate: Option<MoeGate>` and `moe_experts:
Option<Vec<MoeExpert>>` fields, initialized to `None` — actual wire-up
into the forward graph in Phase 3. -->