Files
foxhunt/crates/ml-explainability/tests/ig_diag_cli_integration.rs
jgrusewski 82dca76dae feat(explainability): post-hoc IG diagnostic CLI for DQN checkpoints
Adds `ig_diag` binary under ml-explainability/src/bin/ that runs
Integrated Gradients on a trained DQN safetensors checkpoint and
writes a per-feature attribution report as JSON. Designed for offline
model inspection, not the inference hot path.

Forward target: mean(Q[direction, 0..4]), which simplifies to V(s)
under the dueling identity (mean of centered advantages is zero by
the identifiability constraint). Smooth, no argmax discontinuity,
well-posed for IG.

Regime-head handling: loads only the `trending__`-prefixed weights
(matches RegimeConditionalDQN::load_from_merged_safetensors). Full
multi-head attribution is a future extension.

CLI args:
  --checkpoint PATH        safetensors file
  --states auto|PATH       `auto` samples from test_data/feature-cache/
                           *.fxcache; otherwise a JSON file containing
                           { "states": [[f32; STATE_DIM], ...] }
  --feature-names PATH     JSON array of STATE_DIM names (optional)
  --num-steps N            IG Riemann steps (default 50)
  --output PATH            output JSON (default ig_report.json)
  --auto-samples N         fxcache sample count (default 16)
  --seed N                 LCG seed for reproducibility (default 42)

Output JSON schema:
  {
    "schema_version": 1,
    "checkpoint", "num_steps", "state_dim", "num_states",
    "forward_target", "regime_head",
    "features": [ { "name", "mean_abs", "stddev", "mean_signed" } ],
    "top_10_by_mean_abs": [...],
    "completeness": {
      "worst_relative_error": f64,
      "per_state": [ { "state_idx", "sum_attributions",
                       "f_input_minus_f_baseline", "relative_error" } ]
    }
  }

NoisyNet is disabled on both the Q-network and target network before
IG runs so attributions are deterministic (same checkpoint + states +
num_steps = bit-identical attributions).

Gated behind the `ig-diag-cli` Cargo feature (optional Cargo binary
feature, not a runtime flag) because the binary depends on ml-dqn.
Cannot depend on `ml` due to a cyclic dependency (ml already depends
on ml-explainability). To avoid pulling in the heavy `ml` crate, the
fxcache header parser is reimplemented inline in the binary (~80 LOC,
matches ml/src/fxcache.rs byte-for-byte for v4 files).

Tests:
- tests/ig_diag_cli_integration.rs: end-to-end test that builds a
  DQN, saves a checkpoint, writes a states JSON, invokes the binary
  via std::process::Command, parses the output, asserts schema +
  completeness axiom (worst relative error < 5%). Gated with
  #[cfg(all(feature = "cuda", feature = "ig-diag-cli"))] + #[ignore]
  because it needs CUDA + the binary pre-built.

Build/run:
  cargo build --release -p ml-explainability \
    --features ig-diag-cli --bin ig_diag
  cargo test -p ml-explainability --features ig-diag-cli \
    --test ig_diag_cli_integration -- --ignored --nocapture

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:35:48 +02:00

187 lines
6.8 KiB
Rust

//! End-to-end integration test for the `ig_diag` CLI.
//!
//! 1. Builds a fresh `DQN` with a small config,
//! 2. Saves its branching weights to a safetensors checkpoint with the
//! same plain-name layout that the single-head loader expects,
//! 3. Writes a small states JSON file,
//! 4. Invokes the compiled `ig_diag` binary via `std::process::Command`,
//! 5. Parses the output JSON and asserts the schema + completeness axiom.
//!
//! Gated behind `#[cfg(feature = "cuda")] #[ignore]` because it requires a
//! CUDA runtime and the binary to be built with the `ig-diag-cli` feature.
//! Run with:
//! ```bash
//! cargo build -p ml-explainability --features ig-diag-cli --bin ig_diag
//! cargo test -p ml-explainability --features ig-diag-cli --test \
//! ig_diag_cli_integration -- --ignored --nocapture
//! ```
#![cfg(all(feature = "cuda", feature = "ig-diag-cli"))]
use std::path::PathBuf;
use std::process::Command;
#[test]
#[ignore = "requires CUDA runtime + compiled ig_diag binary"]
fn test_ig_diag_cli_end_to_end() {
// 1. Build a DQN, save a safetensors checkpoint.
let dir = tempfile::tempdir().expect("tempdir");
let ckpt_path = dir.path().join("ig_diag_test.safetensors");
let states_path = dir.path().join("states.json");
let output_path = dir.path().join("ig_report.json");
save_dqn_checkpoint(&ckpt_path);
// 2. Write a small states JSON file (3 random STATE_DIM=96 vectors).
let state_dim = ml_core::state_layout::STATE_DIM;
let states_json = serde_json::json!({
"states": [
(0..state_dim)
.map(|i| ((i as f32 * 0.0123).sin() as f64) * 0.1)
.collect::<Vec<f64>>(),
(0..state_dim)
.map(|i| ((i as f32 * 0.0456 + 1.0).cos() as f64) * 0.2)
.collect::<Vec<f64>>(),
(0..state_dim)
.map(|i| ((i as f32 * 0.0789 - 0.5).sin() as f64) * 0.3)
.collect::<Vec<f64>>(),
],
});
std::fs::write(&states_path, serde_json::to_string(&states_json).unwrap())
.expect("write states.json");
// 3. Invoke the ig_diag binary.
let binary = binary_path();
assert!(
binary.exists(),
"ig_diag binary not found at {} — run: \
cargo build -p ml-explainability --features ig-diag-cli --bin ig_diag",
binary.display(),
);
let status = Command::new(&binary)
.arg("--checkpoint")
.arg(&ckpt_path)
.arg("--states")
.arg(&states_path)
.arg("--num-steps")
.arg("64") // enough steps for <5% completeness on a non-linear DQN
.arg("--output")
.arg(&output_path)
.status()
.expect("spawn ig_diag");
assert!(status.success(), "ig_diag exited non-zero: {status}");
// 4. Parse + validate the output JSON.
let raw = std::fs::read_to_string(&output_path).expect("read output JSON");
let v: serde_json::Value = serde_json::from_str(&raw).expect("parse output JSON");
// Schema checks.
assert_eq!(v["schema_version"], 1);
assert_eq!(v["state_dim"], state_dim);
assert_eq!(v["num_states"], 3);
assert!(
v["features"].is_array(),
"features should be array",
);
assert_eq!(
v["features"].as_array().unwrap().len(),
state_dim,
"one entry per feature",
);
let feat0 = &v["features"][0];
assert!(feat0["mean_abs"].is_number(), "mean_abs should be numeric");
assert!(feat0["stddev"].is_number(), "stddev should be numeric");
assert!(feat0["mean_signed"].is_number(), "mean_signed should be numeric");
let top10 = v["top_10_by_mean_abs"].as_array().expect("top10 array");
assert!(top10.len() <= 10);
assert!(!top10.is_empty(), "top10 must be non-empty");
// Completeness: worst relative error < 5%.
//
// For IG with num_steps=16 on a DQN forward (non-linear), 5% is a loose
// upper bound (in practice it's much tighter). Tighten if needed.
let worst = v["completeness"]["worst_relative_error"]
.as_f64()
.expect("worst_relative_error numeric");
assert!(
worst < 0.05,
"completeness axiom violated: worst relative error {worst} >= 5%",
);
// Per-state completeness entries must be present.
let per_state = v["completeness"]["per_state"]
.as_array()
.expect("per_state array");
assert_eq!(per_state.len(), 3);
println!("ig_diag CLI integration test OK — worst_rel_err={worst:.6}");
}
/// Return the path to the compiled `ig_diag` binary. CARGO_BIN_EXE_* is
/// injected by cargo for integration tests in the same crate; fall back to
/// target/debug if the env var is missing (e.g., when running manually).
fn binary_path() -> PathBuf {
if let Ok(p) = std::env::var("CARGO_BIN_EXE_ig_diag") {
return PathBuf::from(p);
}
// Fallback: walk up from CARGO_MANIFEST_DIR to find workspace/target/debug.
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
while p.pop() {
let candidate = p.join("target").join("debug").join("ig_diag");
if candidate.exists() {
return candidate;
}
}
PathBuf::from("target/debug/ig_diag")
}
/// Build a DQN with small hidden dims, disable NoisyNet, dump its branching
/// weights to a plain-name safetensors file. Mirrors the minimal save path
/// used by `DqnInferenceAdapter`'s round-trip test.
fn save_dqn_checkpoint(path: &std::path::Path) {
use ml_dqn::{DQN, DQNConfig};
let mut config = DQNConfig::emergency_safe_defaults();
config.num_actions = 3;
config.hidden_dims = vec![64, 64];
let dqn = DQN::new(config.clone()).expect("DQN new");
let stream = dqn.cuda_stream().clone();
let branching = dqn
.branching_q_network
.as_ref()
.expect("branching_q_network");
// Download each tensor + serialize with safetensors using the plain-name
// layout ({shared,value,branch}_*). The loader accepts this or the
// regime-prefixed layout.
let named = branching.named_weight_slices();
let byte_vecs: Vec<(String, Vec<usize>, Vec<u8>)> = named
.iter()
.map(|(name, slice, shape)| {
let mut host = vec![0.0_f32; slice.len()];
stream.memcpy_dtoh(*slice, &mut host).expect("DtoH");
let bytes: Vec<u8> = host.iter().flat_map(|v| v.to_le_bytes()).collect();
(name.clone(), shape.clone(), bytes)
})
.collect();
let mut tensor_map = std::collections::HashMap::new();
for (name, shape, bytes) in &byte_vecs {
let view = safetensors::tensor::TensorView::new(
safetensors::Dtype::F32,
shape.clone(),
bytes,
)
.expect("TensorView");
tensor_map.insert(name.as_str(), view);
}
let arch_meta = Some(config.checkpoint_metadata());
safetensors::serialize_to_file(tensor_map, arch_meta, path)
.expect("serialize_to_file");
}