# Additional Training Metrics Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add 17 new Prometheus metrics (12 always-on gauges + 5 opt-in verbose) to provide full RL diagnostics, gradient health, and hyperopt intra-trial visibility across the monitoring pipeline. **Architecture:** Two-tier metrics system. Tier 1 (12 gauges) — zero-cost atomic writes in every trainer's epoch loop. Tier 2 (5 metrics) — gated behind `FOXHUNT_VERBOSE_METRICS=1`. All metrics flow through the existing Prometheus → monitoring_service → gRPC → fxt CLI pipeline. No new queries needed (wildcard `foxhunt_training_*|foxhunt_hyperopt_*` already captures new metrics). **Tech Stack:** Rust, prometheus crate, tonic/prost (gRPC), protobuf, Candle ML framework **Build command:** `SQLX_OFFLINE=true cargo check --workspace` **Test command:** `SQLX_OFFLINE=true cargo test -p --lib` **Clippy rules:** `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::indexing_slicing)]` — use `.get()`, `?`, `.ok_or()` instead. --- ### Task 1: Register Tier 1 metrics + verbose gate in training_metrics.rs **Files:** - Modify: `crates/common/src/metrics/training_metrics.rs` This task adds 12 new gauge registrations, 5 verbose metric registrations with env-var gating, and typed helper functions for all 17 new metrics. **Context:** The file currently has 24 metrics registered in `init()` (lines 32-168) and typed helpers (lines 174-297). Tests are at lines 299-340. The module uses helper functions from `super::` (`register_gauge_vec`, `set_gauge_vec`, `register_histogram_vec`, `observe_histogram_vec`). Labels use `&["model", "fold"]` for training metrics and `&["model"]` for hyperopt metrics. **Step 1: Add the verbose gate** At the top of the file, after the `use super::{...}` block (line 23), add: ```rust use std::sync::OnceLock; static VERBOSE: OnceLock = OnceLock::new(); /// Returns true if `FOXHUNT_VERBOSE_METRICS` env var is set. /// Checked once at first call and cached. pub fn verbose_enabled() -> bool { *VERBOSE.get_or_init(|| std::env::var("FOXHUNT_VERBOSE_METRICS").is_ok()) } ``` **Step 2: Register Tier 1 gauges in `init()`** After the existing `register_gauge("foxhunt_training_active_workers", ...)` block (line 130) and before the `// Hyperopt gauges` comment (line 132), add these 9 training gauges: ```rust // RL diagnostic gauges (model + fold) _ = register_gauge_vec("foxhunt_training_q_value_mean", "Mean Q-value per epoch (DQN)", mf); _ = register_gauge_vec("foxhunt_training_q_value_max", "Max Q-value per epoch (DQN)", mf); _ = register_gauge_vec("foxhunt_training_policy_entropy", "Policy entropy (PPO)", mf); _ = register_gauge_vec("foxhunt_training_kl_divergence", "KL divergence old vs new policy (PPO)", mf); _ = register_gauge_vec("foxhunt_training_advantage_mean", "Mean advantage estimate (PPO)", mf); _ = register_gauge_vec("foxhunt_training_replay_buffer_size", "Replay buffer occupancy (DQN)", mf); // Gradient & training health gauges (model + fold) _ = register_gauge_vec("foxhunt_training_gradient_norm", "L2 gradient norm after backward", mf); _ = register_gauge_vec("foxhunt_training_learning_rate", "Effective learning rate", mf); _ = register_gauge_vec("foxhunt_training_epoch_duration_seconds", "Wall-clock time per epoch", mf); ``` After the existing `register_gauge_vec("foxhunt_hyperopt_mode", ...)` block (line 152) and before the `// Hyperopt counters` comment (line 154), add these 3 hyperopt gauges: ```rust // Hyperopt intra-trial gauges (model only) _ = register_gauge_vec("foxhunt_hyperopt_trial_epoch", "Current epoch within active trial", m); _ = register_gauge_vec("foxhunt_hyperopt_trial_best_loss", "Best loss in current trial", m); _ = register_gauge_vec("foxhunt_hyperopt_elapsed_seconds", "Total wall-clock time since hyperopt start", m); ``` **Step 3: Register Tier 2 verbose metrics in `init()`** At the end of `init()`, before the closing `}`, add: ```rust // Tier 2: Verbose metrics (only registered when FOXHUNT_VERBOSE_METRICS is set) if verbose_enabled() { let mfl: &[&str] = &["model", "fold", "layer"]; _ = register_histogram_vec( "foxhunt_training_batch_loss", "Per-batch loss distribution", mf, vec![0.001, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0], ); _ = register_histogram_vec( "foxhunt_training_gradient_norm_per_layer", "Per-layer gradient L2 norm", mfl, vec![0.0001, 0.001, 0.01, 0.1, 1.0, 10.0, 100.0], ); _ = register_gauge_vec("foxhunt_training_replay_priority_mean", "Mean PER priority", mf); _ = register_gauge_vec("foxhunt_training_advantage_std", "Advantage standard deviation (PPO)", mf); _ = register_gauge_vec("foxhunt_training_value_explained_variance", "Value function explained variance (PPO)", mf); } ``` **Step 4: Add typed helpers for Tier 1** After the existing `set_hyperopt_mode` helper (line 297), add: ```rust // --------------------------------------------------------------------------- // Tier 1: RL diagnostics // --------------------------------------------------------------------------- pub fn set_q_value_stats(model: &str, fold: &str, mean: f64, max: f64) { set_gauge_vec("foxhunt_training_q_value_mean", &[model, fold], mean); set_gauge_vec("foxhunt_training_q_value_max", &[model, fold], max); } pub fn set_policy_entropy(model: &str, fold: &str, entropy: f64) { set_gauge_vec("foxhunt_training_policy_entropy", &[model, fold], entropy); } pub fn set_kl_divergence(model: &str, fold: &str, kl: f64) { set_gauge_vec("foxhunt_training_kl_divergence", &[model, fold], kl); } pub fn set_advantage_mean(model: &str, fold: &str, mean: f64) { set_gauge_vec("foxhunt_training_advantage_mean", &[model, fold], mean); } pub fn set_replay_buffer_size(model: &str, fold: &str, size: f64) { set_gauge_vec("foxhunt_training_replay_buffer_size", &[model, fold], size); } // --------------------------------------------------------------------------- // Tier 1: Gradient & training health // --------------------------------------------------------------------------- pub fn set_gradient_norm(model: &str, fold: &str, norm: f64) { set_gauge_vec("foxhunt_training_gradient_norm", &[model, fold], norm); } pub fn set_learning_rate(model: &str, fold: &str, lr: f64) { set_gauge_vec("foxhunt_training_learning_rate", &[model, fold], lr); } pub fn set_epoch_duration(model: &str, fold: &str, secs: f64) { set_gauge_vec("foxhunt_training_epoch_duration_seconds", &[model, fold], secs); } // --------------------------------------------------------------------------- // Tier 1: Hyperopt intra-trial // --------------------------------------------------------------------------- pub fn set_hyperopt_trial_epoch(model: &str, epoch: f64) { set_gauge_vec("foxhunt_hyperopt_trial_epoch", &[model], epoch); } pub fn set_hyperopt_trial_best_loss(model: &str, loss: f64) { set_gauge_vec("foxhunt_hyperopt_trial_best_loss", &[model], loss); } pub fn set_hyperopt_elapsed(model: &str, secs: f64) { set_gauge_vec("foxhunt_hyperopt_elapsed_seconds", &[model], secs); } // --------------------------------------------------------------------------- // Tier 2: Verbose metrics (no-op when FOXHUNT_VERBOSE_METRICS not set) // --------------------------------------------------------------------------- pub fn observe_batch_loss(model: &str, fold: &str, loss: f64) { if verbose_enabled() { observe_histogram_vec("foxhunt_training_batch_loss", &[model, fold], loss); } } pub fn observe_gradient_norm_per_layer(model: &str, fold: &str, layer: &str, norm: f64) { if verbose_enabled() { observe_histogram_vec("foxhunt_training_gradient_norm_per_layer", &[model, fold, layer], norm); } } pub fn set_replay_priority_mean(model: &str, fold: &str, mean: f64) { if verbose_enabled() { set_gauge_vec("foxhunt_training_replay_priority_mean", &[model, fold], mean); } } pub fn set_advantage_std(model: &str, fold: &str, std: f64) { if verbose_enabled() { set_gauge_vec("foxhunt_training_advantage_std", &[model, fold], std); } } pub fn set_value_explained_variance(model: &str, fold: &str, ev: f64) { if verbose_enabled() { set_gauge_vec("foxhunt_training_value_explained_variance", &[model, fold], ev); } } ``` **Step 5: Add tests** Add these tests inside the existing `#[cfg(test)] mod tests` block, after the last test: ```rust #[test] fn test_verbose_disabled_by_default() { // FOXHUNT_VERBOSE_METRICS not set in test env // verbose helpers should not panic even when metrics aren't registered init(); // These are no-ops when verbose is disabled observe_batch_loss("dqn", "0", 0.05); observe_gradient_norm_per_layer("dqn", "0", "fc1", 0.01); set_replay_priority_mean("dqn", "0", 1.5); set_advantage_std("ppo", "0", 0.8); set_value_explained_variance("ppo", "0", 0.7); } #[test] fn test_tier1_helpers_no_panic() { init(); set_q_value_stats("dqn", "0", 12.3, 45.6); set_policy_entropy("ppo", "0", 1.23); set_kl_divergence("ppo", "0", 0.008); set_advantage_mean("ppo", "0", 0.001); set_replay_buffer_size("dqn", "0", 50000.0); set_gradient_norm("dqn", "0", 1.23); set_learning_rate("dqn", "0", 0.001); set_epoch_duration("dqn", "0", 12.5); set_hyperopt_trial_epoch("dqn", 5.0); set_hyperopt_trial_best_loss("dqn", 0.042); set_hyperopt_elapsed("dqn", 123.4); } ``` **Step 6: Run tests** Run: `SQLX_OFFLINE=true cargo test -p common --lib -- training_metrics` Expected: All tests pass, including the 2 new ones. **Step 7: Clippy check** Run: `SQLX_OFFLINE=true cargo clippy -p common -- -D warnings` Expected: 0 errors, 0 warnings. **Step 8: Commit** ```bash git add crates/common/src/metrics/training_metrics.rs git commit -m "feat(metrics): register 17 new Prometheus metrics with verbose gating" ``` --- ### Task 2: Extend monitoring.proto with new TrainingSession fields **Files:** - Modify: `services/monitoring_service/proto/monitoring.proto` **Context:** `TrainingSession` currently has fields 1-23. The design doc assigns field numbers 24-35 for new fields. Note: the existing hyperopt fields already use numbers 20-23, so the new fields start at 24. **Step 1: Add new fields to TrainingSession** After the `uint32 hyperopt_trials_failed = 23;` line (line 65), add: ```protobuf // RL diagnostics float q_value_mean = 24; float q_value_max = 25; float policy_entropy = 26; float kl_divergence = 27; float advantage_mean = 28; uint32 replay_buffer_size = 29; // Gradient & training health float gradient_norm = 30; float learning_rate = 31; float epoch_duration_seconds = 32; // Hyperopt intra-trial uint32 hyperopt_trial_epoch = 33; float hyperopt_trial_best_loss = 34; float hyperopt_elapsed_seconds = 35; ``` **Step 2: Verify proto compiles** Run: `SQLX_OFFLINE=true cargo check -p monitoring_service` Expected: Compiles (prost generates the new fields as `f32`/`u32` with default 0). Run: `SQLX_OFFLINE=true cargo check -p fxt` Expected: Compiles (fxt also compiles this proto). **Step 3: Commit** ```bash git add services/monitoring_service/proto/monitoring.proto git commit -m "proto(monitoring): add 12 new TrainingSession fields for diagnostics" ``` --- ### Task 3: Wire new metrics through monitoring_service group_into_sessions **Files:** - Modify: `services/monitoring_service/src/service.rs` **Context:** The `group_into_sessions` function (line 98) has a `match s.name.as_str()` block (lines 117-158) that maps Prometheus metric names to TrainingSession proto fields. We add 12 new match arms. **Step 1: Add match arms** Inside the `match s.name.as_str()` block, after the `"foxhunt_hyperopt_mode"` arm (line 153-157) and before the `_ => {}` wildcard (line 158), add: ```rust // RL diagnostics "foxhunt_training_q_value_mean" => session.q_value_mean = s.value as f32, "foxhunt_training_q_value_max" => session.q_value_max = s.value as f32, "foxhunt_training_policy_entropy" => session.policy_entropy = s.value as f32, "foxhunt_training_kl_divergence" => session.kl_divergence = s.value as f32, "foxhunt_training_advantage_mean" => session.advantage_mean = s.value as f32, "foxhunt_training_replay_buffer_size" => { session.replay_buffer_size = s.value as u32; } // Gradient & training health "foxhunt_training_gradient_norm" => session.gradient_norm = s.value as f32, "foxhunt_training_learning_rate" => session.learning_rate = s.value as f32, "foxhunt_training_epoch_duration_seconds" => { session.epoch_duration_seconds = s.value as f32; } // Hyperopt intra-trial "foxhunt_hyperopt_trial_epoch" => session.hyperopt_trial_epoch = s.value as u32, "foxhunt_hyperopt_trial_best_loss" => { session.hyperopt_trial_best_loss = s.value as f32; } "foxhunt_hyperopt_elapsed_seconds" => { session.hyperopt_elapsed_seconds = s.value as f32; } ``` **Step 2: Add tests for new metric mapping** Add a new test in the `#[cfg(test)] mod tests` block: ```rust #[test] fn test_group_new_diagnostic_metrics() { let samples = vec![ MetricSample { name: "foxhunt_training_current_epoch".to_owned(), model: "dqn".to_owned(), fold: "0".to_owned(), value: 25.0, }, MetricSample { name: "foxhunt_training_q_value_mean".to_owned(), model: "dqn".to_owned(), fold: "0".to_owned(), value: 12.3, }, MetricSample { name: "foxhunt_training_q_value_max".to_owned(), model: "dqn".to_owned(), fold: "0".to_owned(), value: 45.6, }, MetricSample { name: "foxhunt_training_gradient_norm".to_owned(), model: "dqn".to_owned(), fold: "0".to_owned(), value: 1.23, }, MetricSample { name: "foxhunt_training_learning_rate".to_owned(), model: "dqn".to_owned(), fold: "0".to_owned(), value: 0.001, }, MetricSample { name: "foxhunt_training_epoch_duration_seconds".to_owned(), model: "dqn".to_owned(), fold: "0".to_owned(), value: 12.5, }, MetricSample { name: "foxhunt_training_replay_buffer_size".to_owned(), model: "dqn".to_owned(), fold: "0".to_owned(), value: 50000.0, }, ]; let sessions = group_into_sessions(&samples, ""); assert_eq!(sessions.len(), 1); let s = &sessions[0]; assert_eq!(s.current_epoch, 25.0); assert!((s.q_value_mean - 12.3).abs() < 0.01); assert!((s.q_value_max - 45.6).abs() < 0.01); assert!((s.gradient_norm - 1.23).abs() < 0.01); assert!((s.learning_rate - 0.001).abs() < 0.0001); assert!((s.epoch_duration_seconds - 12.5).abs() < 0.01); assert_eq!(s.replay_buffer_size, 50000); } #[test] fn test_group_ppo_diagnostics() { let samples = vec![ MetricSample { name: "foxhunt_training_policy_entropy".to_owned(), model: "ppo".to_owned(), fold: "0".to_owned(), value: 1.23, }, MetricSample { name: "foxhunt_training_kl_divergence".to_owned(), model: "ppo".to_owned(), fold: "0".to_owned(), value: 0.008, }, MetricSample { name: "foxhunt_training_advantage_mean".to_owned(), model: "ppo".to_owned(), fold: "0".to_owned(), value: 0.001, }, ]; let sessions = group_into_sessions(&samples, ""); assert_eq!(sessions.len(), 1); let s = &sessions[0]; assert!((s.policy_entropy - 1.23).abs() < 0.01); assert!((s.kl_divergence - 0.008).abs() < 0.001); assert!((s.advantage_mean - 0.001).abs() < 0.001); } #[test] fn test_group_hyperopt_intra_trial() { let samples = vec![ MetricSample { name: "foxhunt_hyperopt_mode".to_owned(), model: "dqn".to_owned(), fold: "".to_owned(), value: 1.0, }, MetricSample { name: "foxhunt_hyperopt_trial_epoch".to_owned(), model: "dqn".to_owned(), fold: "".to_owned(), value: 12.0, }, MetricSample { name: "foxhunt_hyperopt_trial_best_loss".to_owned(), model: "dqn".to_owned(), fold: "".to_owned(), value: 0.042, }, MetricSample { name: "foxhunt_hyperopt_elapsed_seconds".to_owned(), model: "dqn".to_owned(), fold: "".to_owned(), value: 123.4, }, ]; let sessions = group_into_sessions(&samples, ""); assert_eq!(sessions.len(), 1); let s = &sessions[0]; assert!(s.is_hyperopt); assert_eq!(s.hyperopt_trial_epoch, 12); assert!((s.hyperopt_trial_best_loss - 0.042).abs() < 0.001); assert!((s.hyperopt_elapsed_seconds - 123.4).abs() < 0.1); } ``` **Step 3: Run tests** Run: `SQLX_OFFLINE=true cargo test -p monitoring_service --lib` Expected: All 7 tests pass (4 existing + 3 new). **Step 4: Commit** ```bash git add services/monitoring_service/src/service.rs git commit -m "feat(monitoring): wire 12 new metric→proto field mappings in group_into_sessions" ``` --- ### Task 4: Enhance fxt monitor CLI display **Files:** - Modify: `bin/fxt/src/commands/train/monitor.rs` **Context:** The CLI has `print_session_table` (line 127) for the main table, `print_hyperopt_summary` (line 154) for hyperopt, and `print_health_summary` (line 172) for health counters. We add gradient_norm to the main table, an RL diagnostics section, and inline hyperopt trial progress. **Step 1: Add gradient norm column to main table** Replace the `print_session_table` function (lines 127-152) with: ```rust fn print_session_table(sessions: &[TrainingSession]) { println!( "{:<10} {:<6} {:<7} {:<10} {:<10} {:<9} {:<10} {:<10}", "Model".bright_cyan(), "Fold".bright_cyan(), "Epoch".bright_cyan(), "Loss".bright_cyan(), "Val Loss".bright_cyan(), "Batch/s".bright_cyan(), "Grad Norm".bright_cyan(), "Eval Acc".bright_cyan(), ); println!("{}", "-".repeat(82).bright_black()); for s in sessions { let acc = if s.eval_accuracy > 0.0 { format!("{:.1}%", s.eval_accuracy * 100.0) } else { "-".to_owned() }; let grad = if s.gradient_norm > 0.0 { format!("{:.4}", s.gradient_norm) } else { "-".to_owned() }; println!( "{:<10} {:<6} {:<7.0} {:<10.4} {:<10.4} {:<9.1} {:<10} {:<10}", s.model, s.fold, s.current_epoch, s.epoch_loss, s.validation_loss, s.batches_per_second, grad, acc, ); } } ``` **Step 2: Add RL diagnostics section** After `print_session_table`, add a new function: ```rust fn print_rl_diagnostics(sessions: &[TrainingSession]) { let rl_sessions: Vec<_> = sessions .iter() .filter(|s| s.q_value_mean != 0.0 || s.policy_entropy != 0.0) .collect(); if rl_sessions.is_empty() { return; } println!(); println!("{}", "RL Diagnostics:".bright_cyan()); for s in &rl_sessions { if s.q_value_mean != 0.0 || s.q_value_max != 0.0 { // DQN-style println!( " {}: Q-mean={:.2} Q-max={:.2} | buffer={}", s.model.bright_white(), s.q_value_mean, s.q_value_max, s.replay_buffer_size, ); } if s.policy_entropy != 0.0 || s.kl_divergence != 0.0 { // PPO-style println!( " {}: entropy={:.3} KL={:.4} adv-mean={:.4}", s.model.bright_white(), s.policy_entropy, s.kl_divergence, s.advantage_mean, ); } } } ``` **Step 3: Enhance hyperopt summary with intra-trial progress** Replace the `print_hyperopt_summary` function (lines 154-169) with: ```rust fn print_hyperopt_summary(sessions: &[TrainingSession]) { let hyperopt: Vec<_> = sessions.iter().filter(|s| s.is_hyperopt).collect(); if hyperopt.is_empty() { return; } println!(); for s in &hyperopt { let trial_detail = if s.hyperopt_trial_epoch > 0 { format!( " (epoch {}, loss {:.4})", s.hyperopt_trial_epoch, s.hyperopt_trial_best_loss ) } else { String::new() }; let elapsed = if s.hyperopt_elapsed_seconds > 0.0 { format!(" | {:.0}s elapsed", s.hyperopt_elapsed_seconds) } else { String::new() }; println!( "Hyperopt ({}): trial {}/{}{} | best Sharpe {:.2} | {} failures{}", s.model.bright_magenta(), s.hyperopt_trial_current, s.hyperopt_trial_total, trial_detail, s.hyperopt_best_objective, s.hyperopt_trials_failed, elapsed, ); } } ``` **Step 4: Wire RL diagnostics into render_snapshot** In `render_snapshot` (line 81), add a call to `print_rl_diagnostics` between `print_session_table` and `print_hyperopt_summary`: Change lines 100-102 from: ```rust print_session_table(&resp.sessions); print_hyperopt_summary(&resp.sessions); print_health_summary(&resp.sessions); ``` to: ```rust print_session_table(&resp.sessions); print_rl_diagnostics(&resp.sessions); print_hyperopt_summary(&resp.sessions); print_health_summary(&resp.sessions); ``` **Step 5: Verify compilation** Run: `SQLX_OFFLINE=true cargo check -p fxt` Expected: Compiles cleanly. **Step 6: Commit** ```bash git add bin/fxt/src/commands/train/monitor.rs git commit -m "feat(fxt): add grad norm, RL diagnostics, and hyperopt trial detail to monitor" ``` --- ### Task 5: Record new metrics in DQN trainer **Files:** - Modify: `crates/ml/src/trainers/dqn/trainer.rs` **Context:** The DQN trainer already has `use common::metrics::training_metrics;` and records 4 metrics at line 2653. The epoch loop computes `avg_q_value` and `avg_grad_norm` at line 2473. Q-value stats (min, max, mean) are available at line 2580 via `monitor.get_q_value_stats()`. Buffer size is available via `agent.get_replay_buffer_size()` (line 993). **Step 1: Add Tier 1 metrics after existing per-epoch recording** After the existing `training_metrics::set_batches_per_second(...)` block (~line 2661), add: ```rust // Tier 1: RL diagnostics let (q_min, q_max, q_mean) = monitor.get_q_value_stats(); training_metrics::set_q_value_stats("dqn", "current", q_mean as f64, q_max as f64); training_metrics::set_gradient_norm("dqn", "current", avg_grad_norm); training_metrics::set_epoch_duration("dqn", "current", epoch_duration.as_secs_f64()); { let agent = self.agent.read().await; if let Ok(buf_size) = agent.get_replay_buffer_size() { training_metrics::set_replay_buffer_size("dqn", "current", buf_size as f64); } } ``` Note: `q_min`, `q_max`, `q_mean` are already used at line 2580. You need to move/duplicate the call here. The `monitor.get_q_value_stats()` call at line 2580 already exists — use the variables from there or call it again. The simplest approach is to add the metrics recording right after the existing `let (q_min, q_max, q_mean) = monitor.get_q_value_stats();` at line 2580. Actually, the cleanest approach: add metrics recording right after line 2580 where `(q_min, q_max, q_mean)` is already bound: ```rust // After: let (q_min, q_max, q_mean) = monitor.get_q_value_stats(); training_metrics::set_q_value_stats("dqn", "current", q_mean as f64, q_max as f64); training_metrics::set_gradient_norm("dqn", "current", avg_grad_norm); training_metrics::set_epoch_duration("dqn", "current", epoch_duration.as_secs_f64()); { let agent = self.agent.read().await; if let Ok(buf_size) = agent.get_replay_buffer_size() { training_metrics::set_replay_buffer_size("dqn", "current", buf_size as f64); } } ``` Note: `_ = q_min;` may be needed to suppress unused warning if `q_min` was only used in the info log. Check — if `q_min` is already used in the `info!()` macro at line 2582, no suppression needed. **Step 2: Verify compilation** Run: `SQLX_OFFLINE=true cargo check -p ml` Expected: Compiles. Check for unused variable warnings. **Step 3: Commit** ```bash git add crates/ml/src/trainers/dqn/trainer.rs git commit -m "feat(dqn): record Q-value, gradient norm, epoch duration, buffer size metrics" ``` --- ### Task 6: Record new metrics in PPO trainer **Files:** - Modify: `crates/ml/src/trainers/ppo.rs` **Context:** PPO trainer already records 3 metrics at line 668. The epoch loop computes `kl_divergence`, `explained_variance`, `mean_reward`, `std_reward`, `entropy` at line 649 via `self.compute_metrics(...)`. The `PpoEpochMetrics` struct (line 196) has all these fields. **Step 1: Add metrics after existing recording** After the existing `training_metrics::set_validation_loss(...)` at line 670, add: ```rust // Tier 1: PPO diagnostics training_metrics::set_policy_entropy("ppo", "current", entropy as f64); training_metrics::set_kl_divergence("ppo", "current", kl_divergence as f64); training_metrics::set_advantage_mean("ppo", "current", mean_reward as f64); // Tier 2: Verbose PPO metrics (no-op when disabled) training_metrics::set_advantage_std("ppo", "current", std_reward as f64); training_metrics::set_value_explained_variance("ppo", "current", explained_variance as f64); ``` Note: The variables `entropy`, `kl_divergence`, `mean_reward`, `std_reward`, `explained_variance` are computed at line 649 and available in scope. **Step 2: Add gradient norm and epoch duration** The PPO trainer doesn't currently track gradient norms per epoch. We need to find where the optimizer step happens. Search for the backward/optimizer step in the PPO update logic. If a gradient norm is available after the optimizer step, record it. If not, skip this for PPO (the gradient_norm metric will only be populated for DQN and supervised models). Check if the PPO update at `self.ppo_update_step(...)` or similar returns a gradient norm. If not, we only add epoch_duration: After the metrics block, add: ```rust // Epoch duration (epoch_start must be captured at top of loop) // Note: PPO epochs are measured by the progress_callback timing ``` Actually — looking at the PPO trainer structure, the epoch loop at line 492 iterates over `0..self.hyperparams.epochs`. We need to add timing. At the top of the epoch loop body (after `for epoch in 0..self.hyperparams.epochs {`), add: ```rust let epoch_start = std::time::Instant::now(); ``` Then after the metrics block: ```rust training_metrics::set_epoch_duration("ppo", "current", epoch_start.elapsed().as_secs_f64()); ``` **Step 3: Verify compilation** Run: `SQLX_OFFLINE=true cargo check -p ml` Expected: Compiles. **Step 4: Commit** ```bash git add crates/ml/src/trainers/ppo.rs git commit -m "feat(ppo): record entropy, KL, advantage, explained variance, epoch duration metrics" ``` --- ### Task 7: Record new metrics in supervised training binary **Files:** - Modify: `crates/ml/examples/train_baseline_supervised.rs` **Context:** The supervised binary already records epoch/loss/val_loss/iteration_seconds at lines 564-567. The `adapter.get_learning_rate()` value is printed at line 576 but not recorded as a metric. Epoch timing is already measured at line 554 (`epoch_start`). **Step 1: Add learning rate and epoch duration metrics** After the existing metrics block (line 567), add: ```rust metrics::set_learning_rate(model_name, &fold_str, adapter.get_learning_rate()); metrics::set_epoch_duration(model_name, &fold_str, elapsed.as_secs_f64()); ``` Note: `metrics` is the alias for `training_metrics` (imported as `use common::metrics::training_metrics as metrics;` at line 46). `elapsed` is computed at line 561. `adapter.get_learning_rate()` returns `f64`. **Step 2: Verify compilation** Run: `SQLX_OFFLINE=true cargo check --example train_baseline_supervised` Expected: Compiles. (Note: this example may need the full `cargo check -p ml` due to workspace deps.) Actually, the correct command is: Run: `SQLX_OFFLINE=true cargo check -p ml --example train_baseline_supervised` Expected: Compiles. **Step 3: Commit** ```bash git add crates/ml/examples/train_baseline_supervised.rs git commit -m "feat(supervised): record learning rate and epoch duration metrics" ``` --- ### Task 8: Record hyperopt intra-trial metrics in both hyperopt binaries **Files:** - Modify: `crates/ml/examples/hyperopt_baseline_rl.rs` - Modify: `crates/ml/examples/hyperopt_baseline_supervised.rs` **Context:** Both binaries already have `use common::metrics::training_metrics;` and record hyperopt trial/best_objective. They each have a `let start = Instant::now();` at the beginning of each model's optimization function. The `elapsed` is computed after optimization completes. We need to add `elapsed_seconds` recording after optimization completes, before returning results. **Step 1: Add elapsed_seconds to hyperopt_baseline_rl.rs** In `run_dqn_hyperopt()`, after `training_metrics::set_hyperopt_best_objective("dqn", result.best_objective);` (~line 185), add: ```rust training_metrics::set_hyperopt_elapsed("dqn", elapsed); ``` In `run_ppo_hyperopt()`, after `training_metrics::set_hyperopt_best_objective("ppo", result.best_objective);` (~line 254), add: ```rust training_metrics::set_hyperopt_elapsed("ppo", elapsed); ``` **Step 2: Add elapsed_seconds to hyperopt_baseline_supervised.rs** The supervised hyperopt binary has 8 model functions (TFT, Mamba2, Liquid, TGGN, TLOB, KAN, xLSTM, Diffusion). In each function, after the `training_metrics::set_hyperopt_best_objective(MODEL, result.best_objective);` line, add: ```rust training_metrics::set_hyperopt_elapsed(MODEL, elapsed); ``` Where MODEL is the lowercase model name string ("tft", "mamba2", "liquid", "tggn", "tlob", "kan", "xlstm", "diffusion"). Specific locations: - `run_tft_hyperopt()`: after line 150 - `run_mamba2_hyperopt()`: after line 208 - `run_liquid_hyperopt()`: after line 267 - `run_tggn_hyperopt()`: after line 326 - `run_tlob_hyperopt()`: after line 385 - `run_kan_hyperopt()`: after line 444 - `run_xlstm_hyperopt()`: after line 503 - `run_diffusion_hyperopt()`: after line 562 **Step 3: Verify compilation** Run: `SQLX_OFFLINE=true cargo check -p ml --example hyperopt_baseline_rl` Run: `SQLX_OFFLINE=true cargo check -p ml --example hyperopt_baseline_supervised` Expected: Both compile. **Step 4: Commit** ```bash git add crates/ml/examples/hyperopt_baseline_rl.rs crates/ml/examples/hyperopt_baseline_supervised.rs git commit -m "feat(hyperopt): record elapsed_seconds metric in all hyperopt binaries" ``` --- ### Task 9: Full workspace check + clippy **Files:** None (verification only) **Step 1: Full workspace check** Run: `SQLX_OFFLINE=true cargo check --workspace` Expected: 0 errors. **Step 2: Clippy** Run: `SQLX_OFFLINE=true cargo clippy --workspace -- -D warnings` Expected: 0 errors, 0 warnings. **Step 3: Run all affected tests** Run: `SQLX_OFFLINE=true cargo test -p common --lib -- training_metrics` Run: `SQLX_OFFLINE=true cargo test -p monitoring_service --lib` Expected: All tests pass. **Step 4: Commit (if any fixes needed)** ```bash git commit -am "fix: resolve clippy/test issues from metrics additions" ``` Only create this commit if there were issues to fix. If everything passes, skip this step.