# H100 GPU Test Workflow — Design Spec

**Date**: 2026-03-13
**Status**: Approved (rev 2 — post spec-review fixes)
**Scope**: Argo WorkflowTemplate for compile + GPU/CUDA test execution on H100

## Problem

GPU smoke tests take 42+ minutes on a 4GB RTX 3050 Ti in debug mode and OOM on multi-test concurrency. Full model coverage (DQN, PPO, 8 supervised models) is impossible locally. We need a fast, reproducible GPU test path on H100 that validates CUDA hot-path correctness with real market data.

## Decision Summary

| Decision | Choice |
|----------|--------|
| Test scope | Parameterized — all model families by default, selectable subset |
| Test data | Dedicated `test-data-pvc` (10Gi) with curated subset |
| PVC strategy | Two new PVCs — zero contention with training workflows |
| Compile strategy | Compile + test on H100 (single pod, same node = no binary transfer) |
| Triggers | Manual (active) + on-push gated (active) + nightly (suspended) |

## 1. Test Data Infrastructure

### 1.1 test-data-pvc

New PersistentVolumeClaim:
- **Name**: `test-data-pvc`
- **Namespace**: `foxhunt`
- **Size**: 10Gi
- **StorageClass**: `scw-bssd`
- **AccessMode**: `ReadWriteOnce`

Separate from `training-data-pvc` — no RWO contention with training workflows.

### 1.2 Data Layout

```
/data/test-data/
├── ohlcv/ES.FUT/ES.FUT_2025-Q2.dbn.zst      # ~2MB — OHLCV bars
├── mbp10/ES.FUT/ES.FUT_2025-Q2.dbn.zst       # ~126MB — orderbook snapshots
└── trades/ES.FUT/ES.FUT_2025-Q2.dbn.zst      # ~300MB — tick trades
```

**~430MB total** — ES.FUT 2025-Q2 across all three schemas. Exercises every code path: feature extraction, OFI calculation, VPIN, regime classification, walk-forward windows.

### 1.3 Setup Job

`infra/k8s/training/populate-test-data-job.yaml` — one-time Kubernetes Job that:
1. Runs on H100 node pool (same node as test-data-pvc to avoid RWO cross-node)
2. Mounts `training-data-pvc` read-only at `/data/training`
3. Mounts `test-data-pvc` read-write at `/data/test-data`
4. Copies ES.FUT 2025-Q2 files from each schema directory
5. Validates file integrity (size check, dbn header parse)

**Node affinity**: Must schedule on the same node pool as the GPU test workflow (`ci-training-h100`) to ensure the RWO PVC stays on the correct node.

Re-runnable via `scripts/refresh-test-data.sh`.

### 1.4 Test Code Integration

Tests discover data via `TEST_DATA_DIR` environment variable:
- **H100 workflow**: `TEST_DATA_DIR=/data/test-data`
- **Local dev**: Falls back to existing `data/smoke-test/` relative path discovery

**Required code change**: Add `TEST_DATA_DIR` env var lookup as primary path in `try_data()` / `get_dbn_data_dir()` / `get_es_fut_data_dir()` test helpers. This is a prerequisite before the workflow can run.

## 2. Workflow Architecture

### 2.1 WorkflowTemplate: `gpu-test-pipeline`

File: `infra/k8s/argo/gpu-test-pipeline-template.yaml`

DAG structure:
```
gpu-warmup ──────────┐
                     ├──> compile-and-test (H100, single pod)
                     │
notify-result (onExit, always runs)
```

**Key design decision**: Compile AND test run in the **same pod** on the H100. This eliminates:
- Cross-node PVC transfer (BLOCKER in rev 1)
- Non-deterministic test binary path discovery
- GitLab packages upload/download for test binaries
- Complexity of separating compile artifacts from test harness

Cost overhead: ~5 min of H100 time for compilation (~$0.25/run). Acceptable given the simplicity gain.

### 2.2 cargo-target-cuda-test PVC

New PersistentVolumeClaim — **independent from training's `cargo-target-cuda`**:
- **Name**: `cargo-target-cuda-test`
- **Namespace**: `foxhunt`
- **Size**: 30Gi
- **StorageClass**: `scw-bssd-retain` (survives PVC deletion, matches sccache pattern)
- **AccessMode**: `ReadWriteOnce`

Contains persistent git checkout + cargo target directory (same pattern as `cargo-target-cuda` in compile-and-train). Only changed files get new mtimes → cargo skips recompiling unchanged crates.

**Zero contention**: Training workflows use `cargo-target-cuda`, test workflows use `cargo-target-cuda-test`. Neither blocks the other.

### 2.3 Compile-and-Test Step

Single pod on `ci-training-h100`:

- **Image**: `ci-builder` (CUDA 12.9 + Rust 1.89 + mold)
- **Node pool**: `ci-training-h100`
- **Resources**:
  ```yaml
  requests:
    nvidia.com/gpu: "1"
    cpu: "4"
    memory: 32Gi
  limits:
    nvidia.com/gpu: "1"
    cpu: "8"
    memory: 64Gi
  ```
- **Tolerations**:
  ```yaml
  - key: nvidia.com/gpu
    operator: Exists
    effect: NoSchedule
  - key: node.cilium.io/agent-not-ready
    operator: Exists
    effect: NoSchedule
  ```
- **Volumes**:
  - `cargo-target-cuda-test` PVC at `/cargo-target` — persistent checkout + incremental build
  - `test-data-pvc` PVC read-only at `/data/test-data`
  - `git-ssh-key` secret at `/etc/git-ssh`
- **Environment**:
  ```bash
  SQLX_OFFLINE=true
  CARGO_TARGET_DIR=/cargo-target
  CARGO_HOME=/cargo-target/cargo-home
  CARGO_TERM_COLOR=always
  CUDA_COMPUTE_CAP=90          # H100 Hopper architecture
  CUDA_VISIBLE_DEVICES=0
  CUBLAS_WORKSPACE_CONFIG=:4096:8  # Deterministic cuBLAS for reproducible tests
  TEST_DATA_DIR=/data/test-data
  RUST_LOG=info
  ```
- **Execution flow**:
  ```bash
  # 1. Checkout (persistent on PVC, incremental)
  git fetch origin && git checkout --force $SHA

  # 2. Compile test binaries (incremental, ~5-6 min warm)
  cargo test -p ml -p ml-dqn -p ml-core --features cuda --no-run

  # 3. Run tests sequentially per model (see section 4)
  # Captures per-model exit codes, continues on failure
  ```
- **Expected duration**: ~5-6 min compile (warm) + ~15-20 min tests = **~20-25 min total**

### 2.4 GPU Warmup Step

Runs in parallel with nothing (triggers H100 autoscale while the workflow is starting):

```yaml
- 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", "echo 'GPU warmup: node scheduled' && exit 0"]
    resources:
      requests:
        nvidia.com/gpu: "1"
        cpu: 100m
        memory: 64Mi
      limits:
        nvidia.com/gpu: "1"
        cpu: 200m
        memory: 128Mi
```

Same pattern as `compile-and-train-template.yaml:265-291`. Saves 3-5 min of autoscale wait.

### 2.5 Notify Step (onExit)

Uses `onExit` handler (NOT a DAG dependency) — **always runs** regardless of test success/failure:

```yaml
onExit: notify-result
```

Same pattern as `compile-and-train-template.yaml:22` and `ci-pipeline-template.yaml:13`. Posts per-model pass/fail summary to Mattermost webhook.

### 2.6 Workflow Metadata

```yaml
securityContext:
  fsGroup: 0                          # Match compile-and-train
podGC:
  strategy: OnPodCompletion           # Match ci-pipeline
ttlStrategy:
  secondsAfterCompletion: 3600        # 1 hour cleanup
activeDeadlineSeconds: 5400           # 90 min hard limit (generous for 25 min expected)
serviceAccountName: argo-workflow
```

## 3. Parameterization

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `models` | string | `dqn,ppo,tft` | Comma-separated model list |
| `test-scope` | enum | `all` | `lib`, `integration`, or `all` |
| `epochs` | int | `3` | Training epochs for e2e smoke tests |
| `commit-ref` | string | `HEAD` | Git ref to compile and test (SHA, branch, or tag) |
| `gpu-pool` | string | `ci-training-h100` | GPU node pool name |
| `cuda-compute-cap` | string | `90` | CUDA compute capability (90=H100) |
| `notify` | bool | `true` | Send Mattermost notification |

**Full model list**: `dqn,ppo,tft,mamba2,tggn,tlob,liquid,kan,xlstm,diffusion`

**Special value**: `models=all` expands to the full 10-model list.

## 4. Test Matrix

### 4.1 Execution Strategy

Tests run sequentially per model family in a single pod. **Continue on failure**: each model's tests run regardless of previous model failures. Exit codes are captured per-model and aggregated at the end.

```bash
RESULTS=""
run_model_tests() {
  MODEL="$1"; shift
  echo "=== Testing: $MODEL ==="
  "$@" --nocapture 2>&1 | tee "/tmp/test-${MODEL}.log"
  EXIT=${PIPESTATUS[0]}
  RESULTS="${RESULTS}${MODEL}:${EXIT}\n"
  return 0  # Always continue
}
```

### 4.2 DQN Tests

```bash
# lib tests
run_model_tests dqn-lib cargo test -p ml-dqn --features cuda --lib
run_model_tests dqn-ml-lib cargo test -p ml --features cuda --lib -- dqn

# integration tests (if scope=integration or scope=all)
run_model_tests dqn-smoke cargo test -p ml --features cuda --test smoke_test_real_data
run_model_tests dqn-pipeline cargo test -p ml --features cuda --test dqn_training_pipeline_test
run_model_tests dqn-smoke-train cargo test -p ml --features cuda --test dqn_training_smoke_test
run_model_tests dqn-early-stop cargo test -p ml --features cuda --test dqn_early_stopping_termination_test
run_model_tests dqn-collapse cargo test -p ml --features cuda --test dqn_action_collapse_fix_test
```

### 4.3 PPO Tests

```bash
run_model_tests ppo-lib cargo test -p ml --features cuda --lib -- ppo
run_model_tests ppo-barrier cargo test -p ml --features cuda --test barrier_optimization_test
```

### 4.4 Supervised Model Tests

Per model (tft, mamba2, tggn, tlob, liquid, kan, xlstm, diffusion):
```bash
run_model_tests ${MODEL}-lib cargo test -p ml --features cuda --lib -- $MODEL
run_model_tests ${MODEL}-integ cargo test -p ml --features cuda --test "${MODEL}_*" 2>/dev/null || true
```

### 4.5 Shared/Core Tests

Always run regardless of model selection:
```bash
run_model_tests core cargo test -p ml-core --features cuda --lib
run_model_tests bayesian cargo test -p ml --features cuda --test bayesian_changepoint_test
```

### 4.6 Final Aggregation

```bash
# Print summary
echo "=== GPU TEST RESULTS ==="
printf "$RESULTS" | while IFS=: read model exit; do
  [ "$exit" = "0" ] && STATUS="PASS" || STATUS="FAIL"
  printf "  %-20s %s\n" "$model" "$STATUS"
done

# Exit with failure if ANY model failed
printf "$RESULTS" | grep -v ":0$" && exit 1 || exit 0
```

## 5. Trigger Configuration

### 5.1 Manual

`scripts/argo-test.sh` — CLI wrapper for `argo submit`:

```bash
# Full suite (default)
./scripts/argo-test.sh

# Quick DQN-only
./scripts/argo-test.sh --models dqn --scope lib

# All 10 models, 5 epochs
./scripts/argo-test.sh --models all --epochs 5

# Watch logs
./scripts/argo-test.sh --models dqn,ppo --watch
```

**Status**: Active from day one.

### 5.2 On-Push (Gated)

New DAG task in `ci-pipeline-template.yaml`:

```yaml
- name: gpu-test
  depends: "detect-changes"
  templateRef:
    name: gpu-test-pipeline
    template: pipeline
  arguments:
    parameters:
      - name: commit-ref
        value: "{{workflow.parameters.commit-sha}}"
      - name: models
        value: "dqn,ppo,tft"
      - name: epochs
        value: "3"
  when: "{{tasks.detect-changes.outputs.parameters.ml-changed}} == true"
```

**Change detection**: New `ml-changed` output added to `detect-changes` template. `true` when `git diff` includes files matching:
- `crates/ml/**` (includes `src/cuda_pipeline/*.cu` and `*.cuh`)
- `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/**`

**Implementation**: Add to the `check_paths` section of `detect-changes`:
```bash
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")
echo -n "$ML_CHANGED" > /tmp/outputs/ml-changed
```

And add the output parameter:
```yaml
- name: ml-changed
  valueFrom:
    path: /tmp/outputs/ml-changed
```

**Status**: Active from day one. Gated — zero cost when non-ML code changes.

### 5.3 Nightly

`infra/k8s/argo/gpu-test-nightly-cron.yaml`:

```yaml
apiVersion: argoproj.io/v1alpha1
kind: CronWorkflow
metadata:
  name: gpu-test-nightly
  namespace: foxhunt
spec:
  schedule: "0 2 * * *"
  suspend: true  # Created but not active — enable when ready
  workflowSpec:
    workflowTemplateRef:
      name: gpu-test-pipeline
    arguments:
      parameters:
        - name: models
          value: "dqn,ppo,tft,mamba2,tggn,tlob,liquid,kan,xlstm,diffusion"
        - name: epochs
          value: "5"
        - name: commit-ref
          value: "main"
```

**Status**: Created but `suspend: true`. Enable with:
```bash
kubectl patch cronworkflow gpu-test-nightly -n foxhunt -p '{"spec":{"suspend":false}}'
```

## 6. File Inventory

### New files to create

| File | Purpose |
|------|---------|
| `infra/k8s/argo/gpu-test-pipeline-template.yaml` | Main WorkflowTemplate |
| `infra/k8s/argo/gpu-test-nightly-cron.yaml` | CronWorkflow (suspended) |
| `infra/k8s/argo/cargo-target-cuda-test-pvc.yaml` | 30Gi build cache PVC |
| `infra/k8s/training/test-data-pvc.yaml` | 10Gi test data PVC |
| `infra/k8s/training/populate-test-data-job.yaml` | One-time data copy job |
| `scripts/argo-test.sh` | Manual trigger CLI |
| `scripts/refresh-test-data.sh` | Re-run populate job |

### Files to modify

| File | Change |
|------|--------|
| `infra/k8s/argo/ci-pipeline-template.yaml` | Add `gpu-test` DAG task + `ml-changed` output to detect-changes |
| `crates/ml/tests/smoke_test_real_data.rs` | Add `TEST_DATA_DIR` env var to `try_data()` |
| `crates/ml/tests/dqn_training_pipeline_test.rs` | Add `TEST_DATA_DIR` env var to `get_es_fut_data_dir()` |
| `crates/ml/tests/dqn_training_smoke_test.rs` | Add `TEST_DATA_DIR` env var to `get_dbn_data_dir()` |
| `crates/ml/tests/dqn_early_stopping_termination_test.rs` | Add `TEST_DATA_DIR` env var to `get_dbn_data_dir()` |

## 7. Cost Estimate

| Trigger | H100 time/run | Frequency | Monthly cost estimate |
|---------|--------------|-----------|----------------------|
| Manual | ~25 min | ~5/week | ~$10 |
| On-push (gated) | ~25 min | ~10/week | ~$20 |
| Nightly (when enabled) | ~35 min | 30/month | ~$50 |

Based on Scaleway H100 pricing for `ci-training-h100` node pool. Includes ~5 min compile overhead on GPU node (simpler architecture vs CPU-compile + binary transfer).

## 8. Blocker Resolutions (from rev 1 review)

| Blocker | Resolution |
|---------|-----------|
| Workspace PVC can't cross nodes (RWO) | Eliminated: compile + test in same H100 pod |
| `cargo-target-cuda` contention | New `cargo-target-cuda-test` PVC — zero sharing |
| sccache not configured | Removed: using cargo incremental compilation (persistent checkout on PVC), matching `compile-and-train` pattern |
| Non-deterministic test binary paths | Eliminated: `cargo test` runs in same env that compiled — no binary discovery needed |
| No `activeDeadlineSeconds` | Added: 5400s (90 min) |
| No `onExit` handler | Added: `onExit: notify-result` |
| No `podGC` | Added: `OnPodCompletion` |
| Missing `fsGroup` | Added: `fsGroup: 0` |
| Missing `gpu-warmup` | Added: parallel warmup step |
| Incomplete change detection | Added: ml-ensemble, ml-hyperopt, ml-labeling paths |
| Missing `CUDA_COMPUTE_CAP` | Added: `cuda-compute-cap` parameter, default `90` |
| TEST_DATA_DIR not in test helpers | Marked as required prerequisite code change |
| No partial failure handling | Added: continue-on-failure with per-model exit code capture |

## 9. Success Criteria

1. `scripts/argo-test.sh` submits workflow and all default tests pass on H100
2. On-push trigger fires on ML crate changes, skips on non-ML changes
3. All 8 smoke_test_real_data tests pass with test-data-pvc data
4. DQN e2e training loop completes 3 epochs in <5 min (vs 42 min local)
5. Per-model pass/fail reported to Mattermost
6. No interference with training workflows (separate PVCs)
7. `activeDeadlineSeconds` kills stuck workflows within 90 min
8. Completed pods cleaned up via `podGC`
