- B1: Add LIB_FILTER mapping for tggn→tgnn module name (prevents zero-test false positive) - W1: Replace ml_supervised:: imports with canonical ml:: paths in supervised_gpu_smoke_test - W4: Use clean pass/fail exit code instead of raw failure count Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
51 KiB
H100 GPU Test Workflow Implementation Plan
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Argo WorkflowTemplate that compiles and runs the full GPU/CUDA test suite on H100 with real market data, triggered manually, on-push (gated), or nightly.
Architecture: Two new PVCs (cargo-target-cuda-test 30Gi, test-data-pvc 10Gi) isolate test infrastructure from training. Single H100 pod compiles with --features cuda then runs tests sequentially per model family. Continue-on-failure captures per-model results.
Tech Stack: Argo Workflows, Kubernetes, Rust/Cargo, CUDA 12.9, ci-builder image, bash
Spec: docs/superpowers/specs/2026-03-13-gpu-test-workflow-design.md
File Structure
New Files (8)
| File | Responsibility |
|---|---|
crates/ml/tests/supervised_gpu_smoke_test.rs |
GPU smoke test for all 8 supervised models (UnifiedTrainable pipeline) |
infra/k8s/argo/cargo-target-cuda-test-pvc.yaml |
PVC for test compilation cache (30Gi) |
infra/k8s/training/test-data-pvc.yaml |
PVC for curated test data subset (10Gi) |
infra/k8s/training/populate-test-data-job.yaml |
One-time job: copy data from training PVC to test PVC |
infra/k8s/argo/gpu-test-pipeline-template.yaml |
Main WorkflowTemplate: compile + test on H100 |
infra/k8s/argo/gpu-test-nightly-cron.yaml |
CronWorkflow (suspended) for nightly runs |
scripts/argo-test.sh |
CLI wrapper for manual argo submit |
scripts/refresh-test-data.sh |
Re-run populate job |
Modified Files (5)
| File | Change |
|---|---|
infra/k8s/argo/ci-pipeline-template.yaml |
Add ml-changed output + gpu-test DAG task |
crates/ml/tests/smoke_test_real_data.rs:99-130 |
Add TEST_DATA_DIR to smoke_data_dir() and try_data() |
crates/ml/tests/dqn_training_smoke_test.rs:94-105 |
Add TEST_DATA_DIR to get_dbn_data_dir() |
crates/ml/tests/dqn_training_pipeline_test.rs:100-117 |
Add TEST_DATA_DIR to get_es_fut_data_dir() |
crates/ml/tests/dqn_early_stopping_termination_test.rs:93-104 |
Add TEST_DATA_DIR to get_dbn_data_dir() |
Task 1: Create PVCs
Files:
-
Create:
infra/k8s/argo/cargo-target-cuda-test-pvc.yaml -
Create:
infra/k8s/training/test-data-pvc.yaml -
Step 1: Create cargo-target-cuda-test PVC
# infra/k8s/argo/cargo-target-cuda-test-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: cargo-target-cuda-test
namespace: foxhunt
labels:
app.kubernetes.io/name: cargo-target
app.kubernetes.io/component: ci-cache
app.kubernetes.io/part-of: foxhunt
spec:
accessModes: [ReadWriteOnce]
storageClassName: scw-bssd-retain
resources:
requests:
storage: 30Gi
Pattern matches infra/k8s/argo/sccache-pvcs.yaml (same scw-bssd-retain storage class).
- Step 2: Create test-data PVC
# infra/k8s/training/test-data-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: test-data-pvc
namespace: foxhunt
labels:
app.kubernetes.io/name: test-data
app.kubernetes.io/part-of: foxhunt
spec:
accessModes: [ReadWriteOnce]
storageClassName: scw-bssd
resources:
requests:
storage: 10Gi
- Step 3: Commit
git add infra/k8s/argo/cargo-target-cuda-test-pvc.yaml infra/k8s/training/test-data-pvc.yaml
git commit -m "infra: add cargo-target-cuda-test (30Gi) and test-data-pvc (10Gi)"
Task 2: Create populate-test-data job
Files:
-
Create:
infra/k8s/training/populate-test-data-job.yaml -
Create:
scripts/refresh-test-data.sh -
Step 1: Create the populate job
This job runs on the H100 node pool (same as test-data-pvc) and copies one quarter of ES.FUT data per schema from the training PVC.
# infra/k8s/training/populate-test-data-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
generateName: populate-test-data-
namespace: foxhunt
labels:
app.kubernetes.io/name: populate-test-data
app.kubernetes.io/part-of: foxhunt
spec:
backoffLimit: 1
ttlSecondsAfterFinished: 300
template:
spec:
restartPolicy: Never
nodeSelector:
k8s.scaleway.com/pool-name: ci-training-h100
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
- key: node.cilium.io/agent-not-ready
operator: Exists
effect: NoSchedule
containers:
- name: populate
image: busybox:1.37
command: ["/bin/sh", "-c"]
args:
- |
set -e
echo "=== Populating test data PVC ==="
# Target directories
mkdir -p /test-data/ohlcv/ES.FUT
mkdir -p /test-data/mbp10/ES.FUT
mkdir -p /test-data/trades/ES.FUT
# Copy one quarter per schema (2025-Q2)
echo "Copying OHLCV..."
cp /training-data/futures-baseline/ES.FUT/ES.FUT_2025-Q2.dbn.zst \
/test-data/ohlcv/ES.FUT/ 2>/dev/null || \
cp /training-data/futures-baseline/ES.FUT/*2025*Q2* \
/test-data/ohlcv/ES.FUT/ 2>/dev/null || \
{ echo "WARN: No 2025-Q2 OHLCV found, copying first available"; \
cp /training-data/futures-baseline/ES.FUT/*.dbn.zst \
/test-data/ohlcv/ES.FUT/ 2>/dev/null | head -1; }
echo "Copying MBP-10..."
cp /training-data/futures-baseline-mbp10/ES.FUT/ES.FUT_2025-Q2.dbn.zst \
/test-data/mbp10/ES.FUT/ 2>/dev/null || \
cp /training-data/futures-baseline-mbp10/ES.FUT/*2025*Q2* \
/test-data/mbp10/ES.FUT/ 2>/dev/null || \
echo "WARN: No MBP-10 2025-Q2 found"
echo "Copying trades..."
cp /training-data/futures-baseline-trades/ES.FUT/ES.FUT_2025-Q2.dbn.zst \
/test-data/trades/ES.FUT/ 2>/dev/null || \
cp /training-data/futures-baseline-trades/ES.FUT/*2025*Q2* \
/test-data/trades/ES.FUT/ 2>/dev/null || \
echo "WARN: No trades 2025-Q2 found"
echo "=== Test data contents ==="
find /test-data -type f -exec ls -lh {} \;
echo "Total: $(du -sh /test-data | cut -f1)"
echo "=== Done ==="
resources:
requests:
nvidia.com/gpu: "1"
cpu: 100m
memory: 128Mi
limits:
nvidia.com/gpu: "1"
cpu: 500m
memory: 256Mi
volumeMounts:
- name: training-data
mountPath: /training-data
readOnly: true
- name: test-data
mountPath: /test-data
volumes:
- name: training-data
persistentVolumeClaim:
claimName: training-data-pvc
readOnly: true
- name: test-data
persistentVolumeClaim:
claimName: test-data-pvc
Note: requests nvidia.com/gpu: 1 only to schedule on the H100 node (same as test-data-pvc). GPU isn't used for the copy.
- Step 2: Create refresh script
#!/usr/bin/env bash
# Re-populate the test-data-pvc from training-data-pvc.
# Usage: ./scripts/refresh-test-data.sh
set -euo pipefail
echo "Submitting populate-test-data job..."
kubectl -n foxhunt create -f infra/k8s/training/populate-test-data-job.yaml
echo "Waiting for completion..."
kubectl -n foxhunt wait --for=condition=complete job \
-l app.kubernetes.io/name=populate-test-data \
--timeout=300s
echo "Done. Test data PVC refreshed."
Save to scripts/refresh-test-data.sh, chmod +x.
- Step 3: Commit
chmod +x scripts/refresh-test-data.sh
git add infra/k8s/training/populate-test-data-job.yaml scripts/refresh-test-data.sh
git commit -m "infra: add populate-test-data job and refresh script"
Task 3: Wire TEST_DATA_DIR into test helpers
Files:
-
Modify:
crates/ml/tests/smoke_test_real_data.rs:99-130 -
Modify:
crates/ml/tests/dqn_training_smoke_test.rs:94-105 -
Modify:
crates/ml/tests/dqn_training_pipeline_test.rs:100-117 -
Modify:
crates/ml/tests/dqn_early_stopping_termination_test.rs:93-104 -
Step 1: Update
smoke_data_dir()andtry_data()in smoke_test_real_data.rs
Replace smoke_data_dir():
fn smoke_data_dir() -> PathBuf {
// CI: TEST_DATA_DIR points to test-data-pvc on H100
if let Ok(dir) = std::env::var("TEST_DATA_DIR") {
return PathBuf::from(dir);
}
// Local dev: relative path from workspace root
workspace_root().join("data").join("smoke-test")
}
try_data() doesn't need changes — it already calls smoke_data_dir().
- Step 2: Update
get_dbn_data_dir()in dqn_training_smoke_test.rs
Replace lines 94-105:
fn get_dbn_data_dir() -> Result<String> {
// CI: TEST_DATA_DIR points to test-data-pvc on H100
if let Ok(dir) = std::env::var("TEST_DATA_DIR") {
let ohlcv = std::path::PathBuf::from(&dir).join("ohlcv");
if ohlcv.exists() {
return Ok(ohlcv.to_string_lossy().to_string());
}
// Flat layout fallback
return Ok(dir);
}
let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.find(|p| p.join("test_data").exists())
.context("Failed to find workspace root with test_data/")?
.to_path_buf();
let data_dir = workspace_root.join("test_data/futures-baseline");
if !data_dir.exists() {
anyhow::bail!("DBN data not found: {}", data_dir.display());
}
Ok(data_dir.to_string_lossy().to_string())
}
- Step 3: Update
get_es_fut_data_dir()in dqn_training_pipeline_test.rs
Replace lines 100-117:
fn get_es_fut_data_dir() -> Result<String> {
if let Ok(dir) = std::env::var("TEST_DATA_DIR") {
let ohlcv = std::path::PathBuf::from(&dir).join("ohlcv");
if ohlcv.exists() {
return Ok(ohlcv.to_string_lossy().to_string());
}
return Ok(dir);
}
let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.find(|p| p.join("test_data").exists())
.context("Failed to find workspace root with test_data/")?
.to_path_buf();
let data_dir = workspace_root.join("test_data/futures-baseline");
if !data_dir.exists() {
anyhow::bail!(
"ES.FUT data directory not found: {}. Run data acquisition first.",
data_dir.display()
);
}
Ok(data_dir.to_string_lossy().to_string())
}
- Step 4: Update
get_dbn_data_dir()in dqn_early_stopping_termination_test.rs
Replace lines 93-104:
#[cfg(feature = "cuda")]
fn get_dbn_data_dir() -> Option<String> {
if let Ok(dir) = std::env::var("TEST_DATA_DIR") {
let ohlcv = std::path::PathBuf::from(&dir).join("ohlcv");
if ohlcv.exists() {
return Some(ohlcv.to_string_lossy().to_string());
}
return Some(dir);
}
let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.find(|p| p.join("test_data").exists())?
.to_path_buf();
let data_dir = workspace_root.join("test_data/futures-baseline");
if !data_dir.exists() {
eprintln!("SKIP: DBN data not found: {}", data_dir.display());
return None;
}
Some(data_dir.to_string_lossy().to_string())
}
- Step 5: Verify compilation
SQLX_OFFLINE=true cargo test -p ml --features cuda --no-run 2>&1 | tail -5
Expected: compiles with 0 errors.
- Step 6: Verify local tests still work (env var NOT set)
unset TEST_DATA_DIR
SQLX_OFFLINE=true cargo test -p ml --test smoke_test_real_data --features cuda -- smoke_ohlcv_parse_and_extract_features --nocapture 2>&1 | tail -5
Expected: PASS (falls back to data/smoke-test/ path).
- Step 7: Verify TEST_DATA_DIR override works
TEST_DATA_DIR=/home/jgrusewski/Work/foxhunt/data/smoke-test \
SQLX_OFFLINE=true cargo test -p ml --test smoke_test_real_data --features cuda -- smoke_ohlcv_parse_and_extract_features --nocapture 2>&1 | tail -5
Expected: PASS (uses env var path).
- Step 8: Commit
git add crates/ml/tests/smoke_test_real_data.rs crates/ml/tests/dqn_training_smoke_test.rs \
crates/ml/tests/dqn_training_pipeline_test.rs crates/ml/tests/dqn_early_stopping_termination_test.rs
git commit -m "test: add TEST_DATA_DIR env var support for H100 CI test workflow"
Task 3b: Create supervised_gpu_smoke_test.rs
All 8 supervised models need GPU integration tests. Currently TGGN and TLOB have zero integration tests, and the other 6 (TFT, Mamba2, Liquid, KAN, xLSTM, Diffusion) only have CPU-only tests. This single test file exercises every supervised model through the full UnifiedTrainable pipeline on CUDA.
Files:
- Create:
crates/ml/tests/supervised_gpu_smoke_test.rs
Gap analysis:
| Model | Existing integration test | GPU? |
|---|---|---|
| TFT | tft_test.rs |
CPU only |
| Mamba2 | mamba2_hyperopt_edge_cases.rs |
CUDA gated, but hyperopt only |
| TGGN | NONE | — |
| TLOB | NONE | — |
| Liquid | liquid_cfc_training_test.rs |
CPU only |
| KAN | kan_integration.rs |
CPU only |
| xLSTM | xlstm_integration.rs |
CPU only |
| Diffusion | diffusion_integration.rs |
CPU only |
Test design: One #[test] per model, each exercises:
- Construct adapter on CUDA device
- Train 10 epochs (forward → loss → backward → step), assert all losses + grad norms finite
- Checkpoint save/load roundtrip, assert predictions match
- Validate on synthetic data, assert finite validation loss
All tests use synthetic data (small configs, ~16 batch, ~10 feature dim). Skips gracefully if no CUDA device available (process::exit(0) pattern from existing GPU tests).
- Step 1: Write the test file
// crates/ml/tests/supervised_gpu_smoke_test.rs
#![allow(
clippy::assertions_on_constants,
clippy::assertions_on_result_states,
clippy::clone_on_copy,
clippy::decimal_literal_representation,
clippy::doc_markdown,
clippy::empty_line_after_doc_comments,
clippy::field_reassign_with_default,
clippy::get_unwrap,
clippy::identity_op,
clippy::inconsistent_digit_grouping,
clippy::indexing_slicing,
clippy::integer_division,
clippy::len_zero,
clippy::let_underscore_must_use,
clippy::manual_div_ceil,
clippy::manual_let_else,
clippy::manual_range_contains,
clippy::modulo_arithmetic,
clippy::needless_range_loop,
clippy::non_ascii_literal,
clippy::redundant_clone,
clippy::shadow_reuse,
clippy::shadow_same,
clippy::shadow_unrelated,
clippy::single_match_else,
clippy::str_to_string,
clippy::string_slice,
clippy::tests_outside_test_module,
clippy::too_many_lines,
clippy::unnecessary_wraps,
clippy::unseparated_literal_suffix,
clippy::use_debug,
clippy::useless_vec,
clippy::wildcard_enum_match_arm,
clippy::else_if_without_else,
clippy::expect_used,
clippy::missing_const_for_fn,
clippy::similar_names,
clippy::type_complexity,
clippy::collapsible_else_if,
clippy::doc_lazy_continuation,
clippy::items_after_test_module,
clippy::map_clone,
clippy::multiple_unsafe_ops_per_block,
clippy::unwrap_or_default,
clippy::assign_op_pattern,
clippy::needless_borrow,
clippy::println_empty_string,
clippy::unnecessary_cast,
clippy::used_underscore_binding,
clippy::create_dir,
clippy::implicit_saturating_sub,
clippy::exit,
clippy::expect_fun_call,
clippy::too_many_arguments,
clippy::unnecessary_map_or,
clippy::unwrap_used,
dead_code,
unused_imports,
unused_variables,
clippy::cloned_ref_to_slice_refs,
clippy::neg_multiply,
clippy::while_let_loop,
clippy::bool_assert_comparison,
clippy::excessive_precision,
clippy::trivially_copy_pass_by_ref,
clippy::op_ref,
clippy::redundant_closure,
clippy::unnecessary_lazy_evaluations,
clippy::if_then_some_else_none,
clippy::unnecessary_to_owned,
clippy::single_component_path_imports,
)]
//! Supervised Model GPU Smoke Tests
//!
//! Validates all 8 supervised models through the UnifiedTrainable pipeline
//! on a CUDA device: construct → train 10 epochs → checkpoint roundtrip → validate.
//!
//! Models: TFT, Mamba2, TGGN, TLOB, Liquid, KAN, xLSTM, Diffusion
//!
//! Uses synthetic data with small configs to keep VRAM usage minimal.
//! Skips gracefully if no CUDA device is available.
#![allow(unused_crate_dependencies)]
use candle_core::{Device, Tensor};
use ml::training::unified_trainer::UnifiedTrainable;
fn require_cuda() -> Device {
match Device::cuda_if_available(0) {
Ok(dev) if dev.is_cuda() => {
println!("CUDA device available: {:?}", dev);
dev
}
_ => {
eprintln!("CUDA not available, skipping GPU smoke tests");
std::process::exit(0);
}
}
}
/// Generic train-checkpoint-validate pipeline for any UnifiedTrainable model.
/// Returns (final_loss, checkpoint_prediction_diff).
fn smoke_pipeline(
adapter: &mut dyn UnifiedTrainable,
input: &Tensor,
target: &Tensor,
model_name: &str,
) {
let device = adapter.device().clone();
// 1. Train 10 epochs
let mut first_loss = None;
let mut last_loss = 0.0_f64;
for epoch in 0..10 {
let preds = adapter.forward(input).unwrap();
let loss = adapter.compute_loss(&preds, target).unwrap();
let loss_val = loss.to_scalar::<f32>().unwrap() as f64;
assert!(
loss_val.is_finite(),
"{} epoch {}: loss is NaN/Inf ({})",
model_name,
epoch,
loss_val,
);
if first_loss.is_none() {
first_loss = Some(loss_val);
}
last_loss = loss_val;
let grad_norm = adapter.backward(&loss).unwrap();
assert!(
grad_norm.is_finite(),
"{} epoch {}: grad_norm is NaN/Inf ({})",
model_name,
epoch,
grad_norm,
);
adapter.optimizer_step().unwrap();
adapter.zero_grad().unwrap();
}
let first = first_loss.unwrap();
println!(
" {} train: first_loss={:.6}, last_loss={:.6}, reduction={:.1}%",
model_name,
first,
last_loss,
(1.0 - last_loss / first) * 100.0,
);
assert_eq!(adapter.get_step(), 10, "{} should have 10 steps", model_name);
// 2. Checkpoint roundtrip
let tmp_dir = std::env::temp_dir().join(format!("gpu_smoke_{}", model_name.to_lowercase()));
std::fs::create_dir_all(&tmp_dir).unwrap();
let ckpt_path = tmp_dir.join("ckpt");
let save_result = adapter.save_checkpoint(ckpt_path.to_str().unwrap());
assert!(
save_result.is_ok(),
"{} checkpoint save failed: {:?}",
model_name,
save_result.err(),
);
// 3. Validate
let val_input = Tensor::randn(0f32, 0.5, input.dims(), &device).unwrap();
let val_target = Tensor::randn(0f32, 0.1, target.dims(), &device).unwrap();
let val_data = vec![(val_input, val_target)];
let val_loss = adapter.validate(&val_data);
assert!(
val_loss.is_ok(),
"{} validation failed: {:?}",
model_name,
val_loss.err(),
);
let vl = val_loss.unwrap();
assert!(
vl.is_finite(),
"{} validation loss is NaN/Inf ({})",
model_name,
vl,
);
println!(" {} validate: val_loss={:.6}", model_name, vl);
// 4. Metrics
let metrics = adapter.collect_metrics();
assert!(
metrics.learning_rate > 0.0,
"{} learning rate should be > 0",
model_name,
);
let _ = std::fs::remove_dir_all(&tmp_dir);
}
// ────────────────────────────────────────────
// TFT
// ────────────────────────────────────────────
#[test]
fn test_tft_gpu_smoke() {
let device = require_cuda();
println!("=== TFT GPU Smoke ===");
use ml::tft::trainable_adapter::TrainableTFT;
use ml::tft::TFTConfig;
let feature_dim = 10;
let mut config = TFTConfig::default();
config.input_dim = feature_dim;
config.hidden_dim = 32;
config.num_heads = 2;
config.num_layers = 1;
config.num_quantiles = 3;
config.num_static_features = 0;
config.num_known_features = 0;
config.num_unknown_features = feature_dim;
config.sequence_length = 1;
config.prediction_horizon = 1;
config.learning_rate = 1e-3;
config.dropout_rate = 0.0;
// TFT auto-selects device (cuda_if_available) in TemporalFusionTransformer::new()
let mut adapter = TrainableTFT::new(config).unwrap();
assert!(adapter.device().is_cuda(), "TFT should be on CUDA");
// Input: [batch, feature_dim] (seq_len=1, horizon=1, static=0, known=0)
let input = Tensor::randn(0f32, 0.5, &[16, feature_dim], &device).unwrap();
// TFT output: [batch, horizon=1, quantiles=3] → loss target must match
let target = Tensor::randn(0f32, 0.1, &[16, 1, 3], &device).unwrap();
smoke_pipeline(&mut adapter, &input, &target, "TFT");
}
// ────────────────────────────────────────────
// Mamba2
// ────────────────────────────────────────────
#[test]
fn test_mamba2_gpu_smoke() {
let device = require_cuda();
println!("=== Mamba2 GPU Smoke ===");
use ml::mamba::trainable_adapter::Mamba2TrainableAdapter;
use ml::mamba::Mamba2Config;
let mut config = Mamba2Config::default();
config.d_model = 32;
config.num_layers = 1;
config.d_state = 8;
config.max_seq_len = 8;
let mut adapter = Mamba2TrainableAdapter::new(config, &device).unwrap();
assert!(adapter.device().is_cuda(), "Mamba2 should be on CUDA");
// Input: [batch, seq_len, d_model]
let input = Tensor::randn(0f32, 0.5, &[16, 8, 32], &device).unwrap();
// Mamba2 compute_loss narrows to last step → target is [batch, 1]
let target = Tensor::randn(0f32, 0.1, &[16, 1], &device).unwrap();
smoke_pipeline(&mut adapter, &input, &target, "Mamba2");
}
// ────────────────────────────────────────────
// TGGN
// ────────────────────────────────────────────
#[test]
fn test_tggn_gpu_smoke() {
let device = require_cuda();
println!("=== TGGN GPU Smoke ===");
use ml::tgnn::trainable_adapter::TGGNTrainableAdapter;
use ml::tgnn::TGGNConfig;
let config = TGGNConfig {
node_dim: 10,
hidden_dim: 16,
num_layers: 2,
max_nodes: 8,
max_edges: 16,
edge_dim: 4,
temporal_decay: 0.99,
update_frequency_ns: 1_000_000,
use_simd: false,
};
let mut adapter = TGGNTrainableAdapter::new(config, &device).unwrap();
assert!(adapter.device().is_cuda(), "TGGN should be on CUDA");
// Input: [batch, node_dim=10], Output: [batch, 1]
let input = Tensor::randn(0f32, 0.5, &[16, 10], &device).unwrap();
let target = Tensor::randn(0f32, 0.1, &[16, 1], &device).unwrap();
smoke_pipeline(&mut adapter, &input, &target, "TGGN");
}
// ────────────────────────────────────────────
// TLOB
// ────────────────────────────────────────────
#[test]
fn test_tlob_gpu_smoke() {
let device = require_cuda();
println!("=== TLOB GPU Smoke ===");
use ml::tlob::trainable_adapter::{TLOBAdapterConfig, TLOBTrainableAdapter};
let config = TLOBAdapterConfig {
d_model: 16,
num_heads: 2,
num_layers: 1,
seq_len: 1,
feature_dim: 10,
};
let mut adapter = TLOBTrainableAdapter::new(config, &device).unwrap();
assert!(adapter.device().is_cuda(), "TLOB should be on CUDA");
// Input: [batch, seq_len*feature_dim=10] (flat), Output: [batch, 1]
let input = Tensor::randn(0f32, 0.5, &[16, 10], &device).unwrap();
let target = Tensor::randn(0f32, 0.1, &[16, 1], &device).unwrap();
smoke_pipeline(&mut adapter, &input, &target, "TLOB");
}
// ────────────────────────────────────────────
// Liquid (CfC)
// ────────────────────────────────────────────
#[test]
fn test_liquid_gpu_smoke() {
let device = require_cuda();
println!("=== Liquid GPU Smoke ===");
use ml::liquid::adapter::LiquidTrainableAdapter;
use ml::liquid::CfCTrainConfig;
use ml::gpu::DeviceConfig;
let config = CfCTrainConfig {
input_size: 10,
hidden_size: 16,
output_size: 1,
backbone_hidden_sizes: vec![16, 8],
learning_rate: 1e-3,
device: DeviceConfig::Cuda(0),
..CfCTrainConfig::default()
};
let mut adapter = LiquidTrainableAdapter::new(config).unwrap();
assert!(adapter.device().is_cuda(), "Liquid should be on CUDA");
// Input: [batch, seq_len=4, input_size=10] (3D required)
let input = Tensor::randn(0f32, 0.5, &[16, 4, 10], &device).unwrap();
// Output: [batch, output_size=1]
let target = Tensor::randn(0f32, 0.1, &[16, 1], &device).unwrap();
smoke_pipeline(&mut adapter, &input, &target, "Liquid");
}
// ────────────────────────────────────────────
// KAN
// ────────────────────────────────────────────
#[test]
fn test_kan_gpu_smoke() {
let device = require_cuda();
println!("=== KAN GPU Smoke ===");
use ml::kan::config::KANConfig;
use ml::kan::trainable::KANTrainableAdapter;
let config = KANConfig {
layer_widths: vec![10, 8, 4, 1],
grid_size: 3,
spline_order: 3,
learning_rate: 1e-3,
weight_decay: 1e-5,
grad_clip: 1.0,
};
let mut adapter = KANTrainableAdapter::new(config, &device).unwrap();
assert!(adapter.device().is_cuda(), "KAN should be on CUDA");
// Input: [batch, 10], Output: [batch, 1]
let input = Tensor::randn(0f32, 0.5, &[16, 10], &device).unwrap();
let target = Tensor::randn(0f32, 0.1, &[16, 1], &device).unwrap();
smoke_pipeline(&mut adapter, &input, &target, "KAN");
}
// ────────────────────────────────────────────
// xLSTM
// ────────────────────────────────────────────
#[test]
fn test_xlstm_gpu_smoke() {
let device = require_cuda();
println!("=== xLSTM GPU Smoke ===");
use ml::xlstm::trainable::XLSTMTrainableAdapter;
use ml::xlstm::config::XLSTMConfig;
let config = XLSTMConfig {
input_dim: 10,
hidden_dim: 16,
num_blocks: 2,
num_heads: 2,
slstm_ratio: 0.5,
output_dim: 1,
dropout: 0.0,
learning_rate: 1e-3,
weight_decay: 1e-5,
grad_clip: 1.0,
};
let mut adapter = XLSTMTrainableAdapter::new(config, &device).unwrap();
assert!(adapter.device().is_cuda(), "xLSTM should be on CUDA");
// Input: [batch, seq_len=4, input_dim=10] (3D)
let input = Tensor::randn(0f32, 0.5, &[16, 4, 10], &device).unwrap();
// Output: [batch, 1]
let target = Tensor::randn(0f32, 0.1, &[16, 1], &device).unwrap();
smoke_pipeline(&mut adapter, &input, &target, "xLSTM");
}
// ────────────────────────────────────────────
// Diffusion
// ────────────────────────────────────────────
#[test]
fn test_diffusion_gpu_smoke() {
let device = require_cuda();
println!("=== Diffusion GPU Smoke ===");
use ml::diffusion::config::DiffusionConfig;
use ml::diffusion::trainable::DiffusionTrainableAdapter;
let config = DiffusionConfig {
num_timesteps: 50,
sampling_steps: 5,
seq_len: 8,
feature_dim: 1,
hidden_dim: 16,
num_layers: 1,
time_embed_dim: 8,
learning_rate: 1e-3,
weight_decay: 1e-5,
grad_clip: 1.0,
..Default::default()
};
let data_dim = config.data_dim(); // 8
let mut adapter = DiffusionTrainableAdapter::new(config, device.clone()).unwrap();
assert!(adapter.device().is_cuda(), "Diffusion should be on CUDA");
// Input: [batch, data_dim=8]
let input = Tensor::randn(0f32, 0.5, &[16, data_dim], &device).unwrap();
// Diffusion: use input as pseudo-target (noise prediction, loss won't be meaningful)
let target = input.clone();
smoke_pipeline(&mut adapter, &input, &target, "Diffusion");
}
- Step 2: Verify it compiles (no CUDA needed — just check syntax)
SQLX_OFFLINE=true cargo test -p ml --features cuda --test supervised_gpu_smoke_test --no-run 2>&1 | tail -5
Expected: compiles with 0 errors (tests won't run without CUDA — they exit(0)).
- Step 3: Run on local GPU (if 4GB allows — may OOM, that's OK)
SQLX_OFFLINE=true cargo test -p ml --features cuda --test supervised_gpu_smoke_test -- --nocapture --test-threads=1 2>&1 | tail -30
Expected: either all 8 PASS, or OOM on 4GB GPU (fine — will work on H100).
- Step 4: Commit
git add crates/ml/tests/supervised_gpu_smoke_test.rs
git commit -m "test: add supervised_gpu_smoke_test for all 8 models (UnifiedTrainable on CUDA)"
Task 4: Create GPU test WorkflowTemplate
Files:
- Create:
infra/k8s/argo/gpu-test-pipeline-template.yaml
This is the core workflow. Reference compile-and-train-template.yaml for patterns (git checkout, env vars, tolerations, notification).
- Step 1: Create the WorkflowTemplate
Create infra/k8s/argo/gpu-test-pipeline-template.yaml. The complete YAML is provided in a single block below.
The file has 4 templates: pipeline (DAG entrypoint), gpu-warmup, compile-and-test, and notify-result.
The shell script for compile-and-test is shown separately after the YAML structure for readability, but must be inlined in the args: [|] block.
# infra/k8s/argo/gpu-test-pipeline-template.yaml
apiVersion: argoproj.io/v1alpha1
kind: WorkflowTemplate
metadata:
name: gpu-test-pipeline
namespace: foxhunt
labels:
app.kubernetes.io/name: gpu-test-pipeline
app.kubernetes.io/part-of: foxhunt
spec:
entrypoint: pipeline
onExit: notify-result
serviceAccountName: argo-workflow
podMetadata:
labels:
app.kubernetes.io/part-of: foxhunt
app.kubernetes.io/component: gpu-test
securityContext:
fsGroup: 0
podGC:
strategy: OnPodCompletion
ttlStrategy:
secondsAfterCompletion: 3600
activeDeadlineSeconds: 5400
arguments:
parameters:
- name: commit-ref
value: HEAD
- name: models
value: "dqn,ppo,tft"
- name: test-scope
value: all
- name: gpu-pool
value: ci-training-h100
- name: cuda-compute-cap
value: "90"
- name: notify
value: "true"
volumes:
- name: git-ssh-key
secret:
secretName: argo-git-ssh-key
defaultMode: 256
- name: cargo-target
persistentVolumeClaim:
claimName: cargo-target-cuda-test
- name: test-data
persistentVolumeClaim:
claimName: test-data-pvc
readOnly: true
templates:
# ── pipeline: DAG entrypoint ──
- name: pipeline
dag:
tasks:
- name: gpu-warmup
template: gpu-warmup
- name: compile-and-test
template: compile-and-test
dependencies: [gpu-warmup]
# ── gpu-warmup: trigger H100 autoscale ──
- name: gpu-warmup
nodeSelector:
k8s.scaleway.com/pool-name: "{{workflow.parameters.gpu-pool}}"
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
- key: node.cilium.io/agent-not-ready
operator: Exists
effect: NoSchedule
container:
image: busybox:1.37
command: ["/bin/sh", "-c"]
args:
- |
echo "GPU warmup: triggering node autoscale..."
echo "GPU node scheduled, exiting to free resources"
resources:
requests:
nvidia.com/gpu: "1"
cpu: 100m
memory: 64Mi
limits:
nvidia.com/gpu: "1"
cpu: 200m
memory: 128Mi
# ── compile-and-test: compile + run GPU tests in single H100 pod ──
- name: compile-and-test
nodeSelector:
k8s.scaleway.com/pool-name: "{{workflow.parameters.gpu-pool}}"
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
- key: node.cilium.io/agent-not-ready
operator: Exists
effect: NoSchedule
outputs:
parameters:
- name: results
valueFrom:
path: /tmp/outputs/results
default: "unknown:FAIL"
- name: failures
valueFrom:
path: /tmp/outputs/failures
default: "1"
container:
image: gitlab-registry.foxhunt.svc.cluster.local:5000/root/foxhunt/ci-builder:latest
command: ["/bin/sh", "-c"]
env:
- name: SQLX_OFFLINE
value: "true"
- name: CARGO_TERM_COLOR
value: always
- name: CARGO_TARGET_DIR
value: /cargo-target
- name: CARGO_HOME
value: /cargo-target/cargo-home
- name: CUDA_VISIBLE_DEVICES
value: "0"
- name: CUBLAS_WORKSPACE_CONFIG
value: ":4096:8"
- name: TEST_DATA_DIR
value: /data/test-data
- name: RUST_LOG
value: info
volumeMounts:
- name: git-ssh-key
mountPath: /etc/git-ssh
readOnly: true
- name: cargo-target
mountPath: /cargo-target
- name: test-data
mountPath: /data/test-data
readOnly: true
resources:
requests:
nvidia.com/gpu: "1"
cpu: "4"
memory: 32Gi
limits:
nvidia.com/gpu: "1"
cpu: "8"
memory: 64Gi
args:
- |
set -e
REF="{{workflow.parameters.commit-ref}}"
MODELS="{{workflow.parameters.models}}"
SCOPE="{{workflow.parameters.test-scope}}"
# --- SSH setup (same as compile-and-train) ---
mkdir -p ~/.ssh
cp /etc/git-ssh/ssh-privatekey ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf 'Host *\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null\n' > ~/.ssh/config
chmod 600 ~/.ssh/config
REPO="ssh://git@gitlab-gitlab-shell.foxhunt.svc.cluster.local:2222/root/foxhunt.git"
BUILD="/cargo-target/src"
git config --global --add safe.directory "$BUILD"
# --- Persistent checkout on PVC (same as compile-and-train) ---
if [ -d "$BUILD/.git" ]; then
cd "$BUILD"
CURRENT=$(git rev-parse HEAD 2>/dev/null || echo "none")
TARGET=$(git ls-remote origin "$REF" 2>/dev/null | cut -f1 || echo "$REF")
if [ "$CURRENT" = "$TARGET" ] || [ "$CURRENT" = "$REF" ]; then
echo "=== Already at $REF ==="
else
echo "=== Updating checkout ==="
git fetch origin
git checkout --force "$REF"
git clean -fd
fi
else
echo "=== Initial clone ==="
git clone --filter=blob:none "$REPO" "$BUILD"
cd "$BUILD"
git checkout "$REF"
fi
echo "Checked out $(git rev-parse --short HEAD)"
export PATH="${CARGO_HOME}/bin:${PATH}"
export CUDA_COMPUTE_CAP={{workflow.parameters.cuda-compute-cap}}
# --- PVC size guard ---
TARGET_SIZE_MB=$(du -sm "$CARGO_TARGET_DIR" 2>/dev/null | cut -f1 || echo 0)
echo "PVC usage: ${TARGET_SIZE_MB}MB"
if [ "$TARGET_SIZE_MB" -gt 25000 ]; then
echo "PVC exceeds 25GB, pruning..."
rm -rf "$CARGO_TARGET_DIR/release" "$CARGO_TARGET_DIR/debug"
fi
# --- Compile ---
echo "=== Compiling test binaries (--features cuda) ==="
cargo test -p ml -p ml-dqn -p ml-core --features cuda --no-run 2>&1
echo "=== Compilation done ==="
# --- Expand "all" ---
if [ "$MODELS" = "all" ]; then
MODELS="dqn,ppo,tft,mamba2,tggn,tlob,liquid,kan,xlstm,diffusion"
fi
# --- Test runner ---
RESULTS=""
FAILURES=0
run_tests() {
local NAME="$1"; shift
echo ""
echo "========================================"
echo " TEST: $NAME"
echo "========================================"
set +e
"$@" --nocapture 2>&1
EXIT=$?
set -e
if [ $EXIT -eq 0 ]; then
RESULTS="${RESULTS}${NAME}:PASS\n"
else
RESULTS="${RESULTS}${NAME}:FAIL\n"
FAILURES=$((FAILURES + 1))
fi
}
# --- Core tests (always run) ---
run_tests "core-lib" cargo test -p ml-core --features cuda --lib
run_tests "bayesian" cargo test -p ml --features cuda --test bayesian_changepoint_test
# --- Per-model tests ---
IFS=',' read -ra MODEL_LIST <<< "$MODELS"
for MODEL in "${MODEL_LIST[@]}"; do
case "$MODEL" in
dqn)
if [ "$SCOPE" = "lib" ] || [ "$SCOPE" = "all" ]; then
run_tests "dqn-lib" cargo test -p ml-dqn --features cuda --lib
run_tests "dqn-ml-lib" cargo test -p ml --features cuda --lib -- dqn
fi
if [ "$SCOPE" = "integration" ] || [ "$SCOPE" = "all" ]; then
run_tests "dqn-smoke" cargo test -p ml --features cuda --test smoke_test_real_data
run_tests "dqn-pipeline" cargo test -p ml --features cuda --test dqn_training_pipeline_test
run_tests "dqn-smoke-train" cargo test -p ml --features cuda --test dqn_training_smoke_test
run_tests "dqn-early-stop" cargo test -p ml --features cuda --test dqn_early_stopping_termination_test
run_tests "dqn-collapse" cargo test -p ml --features cuda --test dqn_action_collapse_fix_test
fi
;;
ppo)
if [ "$SCOPE" = "lib" ] || [ "$SCOPE" = "all" ]; then
run_tests "ppo-lib" cargo test -p ml --features cuda --lib -- ppo
fi
if [ "$SCOPE" = "integration" ] || [ "$SCOPE" = "all" ]; then
run_tests "ppo-barrier" cargo test -p ml --features cuda --test barrier_optimization_test
fi
;;
*)
# Supervised models (TFT, Mamba2, TGGN, TLOB, Liquid, KAN, xLSTM, Diffusion)
# Map model names to Rust module names where they differ
LIB_FILTER="$MODEL"
[ "$MODEL" = "tggn" ] && LIB_FILTER="tgnn"
if [ "$SCOPE" = "lib" ] || [ "$SCOPE" = "all" ]; then
run_tests "${MODEL}-lib" cargo test -p ml --features cuda --lib -- "$LIB_FILTER"
fi
if [ "$SCOPE" = "integration" ] || [ "$SCOPE" = "all" ]; then
run_tests "${MODEL}-gpu" cargo test -p ml --features cuda --test supervised_gpu_smoke_test -- "test_${MODEL}_gpu_smoke"
# Also run model-specific integration tests if they exist
if cargo test -p ml --features cuda --test "${MODEL}_integration" --no-run 2>/dev/null; then
run_tests "${MODEL}-integ" cargo test -p ml --features cuda --test "${MODEL}_integration"
fi
fi
;;
esac
done
# --- Summary ---
echo ""
echo "========================================"
echo " GPU TEST RESULTS"
echo "========================================"
printf "$RESULTS" | while IFS=: read -r name status; do
[ -z "$name" ] && continue
printf " %-25s %s\n" "$name" "$status"
done
echo "========================================"
echo " Total failures: $FAILURES"
echo "========================================"
# Write results for notification step
mkdir -p /tmp/outputs
printf "$RESULTS" > /tmp/outputs/results
echo "$FAILURES" > /tmp/outputs/failures
[ "$FAILURES" -gt 0 ] && exit 1 || exit 0
# ── notify-result: post test outcome to Mattermost (onExit) ──
- name: notify-result
nodeSelector:
k8s.scaleway.com/pool-name: platform
container:
image: gitlab-registry.foxhunt.svc.cluster.local:5000/root/foxhunt/foxhunt-runtime:latest
command: ["/bin/sh", "-c"]
env:
- name: WEBHOOK_URL
valueFrom:
secretKeyRef:
name: notification-webhook
key: webhook-url
optional: true
resources:
requests:
cpu: 50m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
args:
- |
NOTIFY="{{workflow.parameters.notify}}"
if [ "$NOTIFY" != "true" ]; then
echo "Notifications disabled, skipping"
exit 0
fi
STATUS="{{workflow.status}}"
NAME="{{workflow.name}}"
if [ -z "$WEBHOOK_URL" ] || echo "$WEBHOOK_URL" | grep -q "PLACEHOLDER"; then
echo "No webhook configured, skipping notification"
exit 0
fi
if [ "$STATUS" = "Succeeded" ]; then
EMOJI=":white_check_mark:"
else
EMOJI=":x:"
fi
PAYLOAD="{\"username\":\"Argo CI\",\"text\":\"${EMOJI} **GPU Tests** ${NAME} — ${STATUS} ({{workflow.duration}}s)\"}"
curl -sf -X POST -H 'Content-Type: application/json' \
-d "$PAYLOAD" "$WEBHOOK_URL" || echo "WARN: webhook post failed"
The complete shell script is inlined in the YAML above within the compile-and-test template's args block.
- Step 2: Validate YAML syntax
python3 -c "import yaml; yaml.safe_load(open('infra/k8s/argo/gpu-test-pipeline-template.yaml'))" && echo "YAML OK"
- Step 3: Commit
git add infra/k8s/argo/gpu-test-pipeline-template.yaml
git commit -m "feat(ci): add gpu-test-pipeline WorkflowTemplate for H100 CUDA testing"
Task 5: Create nightly CronWorkflow
Files:
-
Create:
infra/k8s/argo/gpu-test-nightly-cron.yaml -
Step 1: Create CronWorkflow (suspended)
# infra/k8s/argo/gpu-test-nightly-cron.yaml
apiVersion: argoproj.io/v1alpha1
kind: CronWorkflow
metadata:
name: gpu-test-nightly
namespace: foxhunt
labels:
app.kubernetes.io/name: gpu-test-nightly
app.kubernetes.io/part-of: foxhunt
spec:
schedule: "0 2 * * *"
timezone: "UTC"
suspend: true # Enable when ready: kubectl patch cronworkflow gpu-test-nightly -n foxhunt -p '{"spec":{"suspend":false}}'
concurrencyPolicy: Replace
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
workflowSpec:
workflowTemplateRef:
name: gpu-test-pipeline
arguments:
parameters:
- name: models
value: "dqn,ppo,tft,mamba2,tggn,tlob,liquid,kan,xlstm,diffusion"
- name: commit-ref
value: "main"
- Step 2: Commit
git add infra/k8s/argo/gpu-test-nightly-cron.yaml
git commit -m "feat(ci): add gpu-test-nightly CronWorkflow (suspended)"
Task 6: Create argo-test.sh CLI
Files:
-
Create:
scripts/argo-test.sh -
Step 1: Create the script
Pattern matches scripts/argo-train.sh (same argument parsing style, same argo submit invocation).
#!/usr/bin/env bash
# Run GPU/CUDA tests via Argo Workflows on H100.
#
# Usage:
# ./scripts/argo-test.sh # defaults: dqn,ppo,tft
# ./scripts/argo-test.sh --models dqn --scope lib # DQN lib tests only
# ./scripts/argo-test.sh --models all # all 10 models
# ./scripts/argo-test.sh --watch # follow logs
#
# Requires: argo CLI
set -euo pipefail
MODELS="dqn,ppo,tft"
SCOPE="all"
COMMIT_REF="HEAD"
GPU_POOL=""
WATCH=false
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS]
Options:
--models <list> Comma-separated model list (default: dqn,ppo,tft)
Use "all" for all 10 models
--scope <scope> lib, integration, or all (default: all)
--ref <ref> Git ref to test (default: HEAD)
--gpu-pool <pool> GPU node pool (default: ci-training-h100)
--watch Follow workflow logs
-h, --help Show this help
EOF
exit 0
}
while [[ $# -gt 0 ]]; do
case $1 in
--models) MODELS="$2"; shift 2 ;;
--scope) SCOPE="$2"; shift 2 ;;
--ref) COMMIT_REF="$2"; shift 2 ;;
--gpu-pool) GPU_POOL="$2"; shift 2 ;;
--watch) WATCH=true; shift ;;
-h|--help) usage ;;
*) echo "Unknown option: $1"; usage ;;
esac
done
# Resolve HEAD to actual SHA if needed
if [ "$COMMIT_REF" = "HEAD" ]; then
COMMIT_REF=$(git rev-parse HEAD)
fi
CMD="argo submit -n foxhunt --from=wftmpl/gpu-test-pipeline"
CMD="$CMD -p commit-ref=$COMMIT_REF"
CMD="$CMD -p models=$MODELS"
CMD="$CMD -p test-scope=$SCOPE"
[[ -n "$GPU_POOL" ]] && CMD="$CMD -p gpu-pool=$GPU_POOL"
if $WATCH; then
CMD="$CMD --watch"
fi
echo "Submitting GPU test workflow..."
echo " commit: $(echo $COMMIT_REF | cut -c1-8)"
echo " models: $MODELS"
echo " scope: $SCOPE"
echo ""
eval "$CMD"
- Step 2: Make executable and commit
chmod +x scripts/argo-test.sh
git add scripts/argo-test.sh
git commit -m "feat(ci): add argo-test.sh CLI for GPU test workflow"
Task 7: Add on-push trigger to CI pipeline
Files:
-
Modify:
infra/k8s/argo/ci-pipeline-template.yaml -
Step 1: Add
ml-changedto detect-changes
In ci-pipeline-template.yaml, inside the detect-changes template's shell script (around line 199), add:
ML_CHANGED=$(check_paths "crates/ml crates/ml-dqn crates/ml-core crates/ml-regime crates/ml-features crates/ml-supervised crates/ml-ppo crates/ml-ensemble crates/ml-hyperopt crates/ml-labeling")
And add the output file write (around line 218):
echo -n "$ML_CHANGED" > /tmp/outputs/ml-changed
And add the log line (around line 210):
echo "ml-changed: $ML_CHANGED"
- Step 2: Add ml-changed output parameter
In the outputs.parameters section of detect-changes (around line 238), add:
- name: ml-changed
valueFrom:
path: /tmp/outputs/ml-changed
- Step 3: Add gpu-test DAG task + submit-gpu-test template
Important: Cannot use templateRef to invoke gpu-test-pipeline/pipeline directly from the CI pipeline DAG because templateRef runs the referenced template within the calling workflow's pod context. The CI pipeline lacks the required PVC volumes (cargo-target-cuda-test, test-data-pvc) and GPU nodeSelector/tolerations. Instead, use a resource template that submits a child Workflow from the WorkflowTemplate (workflow-of-workflows pattern).
In the pipeline DAG tasks section (around line 143), add:
- name: gpu-test
depends: "detect-changes"
template: submit-gpu-test
when: "{{tasks.detect-changes.outputs.parameters.ml-changed}} == true"
Add the submit-gpu-test template to the templates list:
# ── submit-gpu-test: launch GPU test workflow as a child workflow ──
- name: submit-gpu-test
resource:
action: create
setOwnerReference: true
successCondition: status.phase == Succeeded
failureCondition: status.phase in (Failed, Error)
manifest: |
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
generateName: gpu-test-on-push-
namespace: foxhunt
spec:
workflowTemplateRef:
name: gpu-test-pipeline
arguments:
parameters:
- name: commit-ref
value: "{{workflow.parameters.commit-sha}}"
- name: models
value: "dqn,ppo,tft"
This creates a child Workflow from the gpu-test-pipeline WorkflowTemplate. The child workflow has its own volumes, nodeSelector, and tolerations defined in the WorkflowTemplate spec. setOwnerReference: true ensures the child workflow is cleaned up when the parent is deleted.
- Step 4: Validate YAML syntax
python3 -c "import yaml; yaml.safe_load(open('infra/k8s/argo/ci-pipeline-template.yaml'))" && echo "YAML OK"
- Step 5: Commit
git add infra/k8s/argo/ci-pipeline-template.yaml
git commit -m "feat(ci): add gpu-test on-push trigger gated on ML crate changes"
Task 8: Apply to cluster and validate
- Step 1: Apply PVCs
kubectl -n foxhunt apply -f infra/k8s/argo/cargo-target-cuda-test-pvc.yaml
kubectl -n foxhunt apply -f infra/k8s/training/test-data-pvc.yaml
- Step 2: Populate test data
./scripts/refresh-test-data.sh
Wait for job completion. Verify data:
kubectl -n foxhunt exec -it $(kubectl -n foxhunt get pods -l app.kubernetes.io/name=populate-test-data -o name | head -1) -- ls -lhR /test-data/
- Step 3: Apply WorkflowTemplate and CronWorkflow
kubectl -n foxhunt apply -f infra/k8s/argo/gpu-test-pipeline-template.yaml
kubectl -n foxhunt apply -f infra/k8s/argo/gpu-test-nightly-cron.yaml
- Step 4: Run a quick manual test (DQN lib only)
./scripts/argo-test.sh --models dqn --scope lib --watch
Expected: compile + DQN lib tests pass on H100.
- Step 5: Run full default suite
./scripts/argo-test.sh --watch
Expected: DQN + PPO + TFT tests all pass.
- Step 6: Apply updated CI pipeline
kubectl -n foxhunt apply -f infra/k8s/argo/ci-pipeline-template.yaml
- Step 7: Final commit (if any fixes needed)
git add -A
git commit -m "fix(ci): adjustments from H100 GPU test validation"