Commit Graph

209 Commits

Author SHA1 Message Date
jgrusewski
5694eb4df2 fix(sp21): T2.2 Phase 8.5 — wire factored-action branch sizes into closure-based eval (atomic)
v7 smoke (train-fv4s8, commit 23b89a90e) eval pod hit
CUDA_ERROR_ILLEGAL_ADDRESS at fold 0:

  Error: DQN fold 0 GPU evaluation failed: GpuBacktestEvaluator::evaluate
    failed for fold 0: Model error: eval_done_event synchronize:
    DriverError(CUDA_ERROR_ILLEGAL_ADDRESS, "an illegal memory access was encountered")

The {:#} anyhow chain fix from Phase 8.3+9 made the failure mode visible.
Diagnosis from code (no second smoke needed): the closure-based
evaluate() path never sets b0_size..b3_size, leaving them at the
default 0. env_step kernel's decode_*_4b helpers do action/(b1*b2*b3)
→ divide-by-zero → garbage decoded indices → out-of-bounds memory
read → CUDA_ERROR_ILLEGAL_ADDRESS at next event-sync.

The production `evaluate_dqn_graphed` path sets b-sizes via
`ensure_action_select_ready` (which also lazy-allocates intent buffers
the closure path doesn't need). The closure-based `evaluate()` path
used by eval-baseline never calls it.

Fix:

  1. Add pub fn `GpuBacktestEvaluator::set_branch_sizes(&mut self,
     dqn_cfg: &DqnBacktestConfig)` — sets b0..b3_size only, no
     buffer allocation.

  2. Add defensive guard in `evaluate()` that bails with
     `MLError::ConfigError` if any b-size is zero. Future regressions
     produce a clear error instead of an opaque CUDA illegal-address.

  3. Wire `set_branch_sizes(&dqn_cfg)` call in
     `evaluate_dqn_fold_gpu` between `DqnBacktestConfig::from_network_dims`
     and the closure-based `evaluator.evaluate(...)`.

Pearls honoured:
  - feedback_no_hiding: zero-b-size now surfaces as ConfigError
    rather than CUDA illegal-address downstream
  - feedback_no_partial_refactor: closure-path was a partial wire-up
    from pre-factored-action days; set_branch_sizes brings it into
    parity with the CUBLAS production path for action decoding
  - pearl_no_deferrals_for_complementary_fixes: v7's chain-exposing
    fix surfaced this; lands immediately not after another smoke

Verification:
  cargo check -p ml --example evaluate_baseline --features cuda  # clean

Note on PPO/supervised paths:
  Their evaluate() calls also lack set_branch_sizes and will now
  trip the defensive guard. Those paths haven't actually run eval
  since STATE_DIM grew past 54 — the silent failure mode had been
  masking it. Future Phase will either wire their action conventions
  (PPO: 5-exposure; supervised: signal thresholds) or delete the
  dead paths per feedback_no_partial_refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:51:31 +02:00
jgrusewski
23b89a90e9 fix(sp21): T2.2 Phase 8.3+9 — eval pipeline GPU-only, hard-fail, delete CPU path (atomic)
Combined Phase 8.3 (visibility + hard-fail) and Phase 9 (CPU path
removal) per pearl_no_deferrals_for_complementary_fixes. Surfaced by
v6 smoke (train-x4m96) where:

  [DQN GPU] Fold 0 GPU eval failed: GpuBacktestEvaluator::evaluate failed
    for fold 0. Falling back to CPU path.
  [DQN] Fold 0 evaluation failed: Failed to create DQN state tensor for
    bar 0: Dimension mismatch: expected 54, got 45

Both fold 0 and fold 1 hit this; workflow exited 0, masking eval
failure for every smoke run since STATE_DIM grew beyond legacy 54.

Root cause (silent GPU failure):
  evaluate_dqn_fold_gpu calls evaluator.evaluate(closure, portfolio_dim: 3)
  but GpuBacktestEvaluator initialises portfolio_dim = PORTFOLIO_BASE_DIM (8)
  + MTF_DIM (16) = 24 per canonical state layout. gather_states asserts
  match → returns MLError::ConfigError. Caller wraps with .with_context()
  + warn!("... {}", e) — `{}` strips anyhow chain, hiding root cause.
  The CPU fallback runs with a separate stale 45-dim state builder
  (42 from extract_ml_features + 3 portfolio) → fails at GpuTensor::
  from_host shape validation against the model's 54-feature default.

Fixes (all atomic):

  1. portfolio_dim: 3 → 24 at all 4 call sites (DQN x2, PPO, supervised).
     The GPU evaluator's gather_kernel handles full 128-dim state
     assembly (Market 42 + OFI 32 + TLOB 16 + MTF 16 + Portfolio 8 +
     PlanISV 7 + Padding 7); caller just declares correct portfolio_dim.

  2. Surface anyhow chain: {} → {:#} in error messages.

  3. Hard-fail on GPU eval failure: anyhow::bail! (no CPU fallback).
     Per user directive: "hard fail on gpu panic, cpu path strictly
     forbidden should be removed entirely!"

  4. DELETE CPU DQN eval path entirely:
     - fn evaluate_dqn_fold
     - fn build_chunk_states (stale 45-dim state builder)
     - fn simulate_chunk_trades
     - fn compute_metrics + struct ComputedMetrics
     - struct PortfolioState

  5. DELETE coupled surrogate-noise machinery:
     - struct SurrogateSampler + impl
     - fn load_surrogate_marginals
     - fn compute_pooled_sharpe
     - Surrogate init blocks in main
     - ACTION_MARGINALS / POOLED_SHARPE emission blocks

  6. DELETE coupled CLI flags:
     - --gpu-eval / --no-gpu-eval (GPU mandatory)
     - --surrogate-mode, --surrogate-seed, --surrogate-marginals
     - --emit-action-marginals, --emit-pooled-sharpe

  7. Collapse `if args.gpu_eval { ... }` blocks to direct calls; cleaner
     control flow, no gpu_handled tracking.

Pearls honoured:
  - feedback_no_cpu_test_fallbacks: GPU oracle only
  - feedback_no_partial_refactor: stale CPU layout from pre-STATE_DIM=128 era
  - feedback_no_hiding: error chain now visible via {:#}
  - feedback_no_legacy_aliases: no deprecated --no-gpu-eval wrapper
  - pearl_no_deferrals_for_complementary_fixes: 8.3+9 combined

Files changed:
  - crates/ml/examples/evaluate_baseline.rs:
      −892 net lines (1066 del, 174 ins; 2817 → 1925)
  - docs/dqn-wire-up-audit.md: 2026-05-12 audit entry

Verification:
  - cargo check -p ml --example evaluate_baseline --features cuda  # clean
  - cargo check --workspace --features cuda                        # clean
  - cargo test -p ml --lib --features cuda financials              # 7/7

Note: OFI/TLOB/MTF feature-set fidelity is a separate concern. The GPU
gather_kernel handles state assembly; caller currently provides zeroed
OFI (LobBar.ofi = 0.0) and no MTF data. Eval will run, but on degraded
features. Faithful feature wiring deferred to a later Phase once
eval-runs-at-all is validated by v7 smoke.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 08:47:05 +02:00
jgrusewski
62b5a50e8b fix(eval): shape-mismatch on checkpoint load — read arch from safetensors metadata (atomic)
Smoke v1 (train-grfcw) evaluate phase failed with "Failed to load DQN
checkpoint" for fold 0 and fold 1. MinIO log inspection confirmed
checkpoints WERE saved (1431144 bytes each) — the failure was
eval-side shape mismatch.

Root cause:
- Training uses STATE_DIM=128 (ml_core::state_layout), num_actions=108
  (factored b0*b1*b2*b3=4*3*3*3), num_order_types=3,
  num_urgency_levels=3.
- evaluate_baseline CLI defaults: --feature-dim=54, --num-actions=5
  (legacy from pre-branching DQN era).
- Loading 128-state-dim 108-action checkpoint into 54-feature 5-action
  net → tensor shape mismatch → `load_from_safetensors` returned
  parse error → `with_context(...)` wrapped it as the generic "Failed
  to load DQN checkpoint" message, hiding the actual shape error.
- Both GPU and CPU eval paths hit the same root cause.

Fix:
Both eval paths now call `DQNConfig::from_safetensors_file(&ckpt_path)`
to read architecture-critical fields from the checkpoint's embedded
metadata (state_dim, num_actions, hidden_dims, num_order_types,
num_urgency_levels, dueling_hidden_dim, num_atoms, gamma). Eval-time
fields (LR, epsilon, buffer caps) overridden; hyperopt-derived gamma/
v_min/v_max applied if present in hyperopt config.

Older checkpoints without embedded metadata fall back to CLI-args-built
config + warn! log. All production SP21+ checkpoints embed metadata
via the existing DQNConfig::checkpoint_metadata path.

Files changed:
- crates/ml/examples/evaluate_baseline.rs: shape-aware config for both
  dqn_eval_gpu_path (line ~1238) and dqn_eval_cpu_path (line ~1029)
- docs/dqn-wire-up-audit.md: 2026-05-11 audit entry

Verification:
- cargo check -p ml --examples --features cuda: 0 errors
- cargo test -p ml --lib financials: 7/7 (unchanged)
- cargo test -p ml --lib sp21_isv_slots: 4/4 (unchanged)

Behavioral gate: smoke v3 (train-psf86, in-flight on 2937da889) won't
have this fix; smoke v4 dispatch on this commit will validate
evaluate phase succeeds for all folds. Look for
"[DQN GPU] Architecture from checkpoint: ..." log line per fold.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:28:53 +02:00
jgrusewski
032026dc07 feat(sp20): parallelise per-bar OFI extraction via time-bucket sharding
Replaces the sequential per-bar OFI loop in
`crates/ml/examples/precompute_features.rs:578-734` with a call into a
new `ml-features` library function that shards bars across CPU cores
with warmup overlap. On `ci-compile-cpu` (28 cores), large fxcache
runs (~50-200k bars) get a meaningful speedup; small datasets (<10k
bars) may run slightly slower due to amortisation cost — see test
output below.

Strategy: time-bucket sharding with warmup overlap

- Partition core bars [0, n) into K contiguous shards (K = rayon
  current threads, or `OFI_SHARD_COUNT` env override).
- For each shard [start_k, end_k):
    1. Construct a fresh `OFICalculator`.
    2. Replay the `WARMUP_BARS = 5000` preceding bars (or fewer if
       start_k < 5000) by feeding trades + calling
       `calculate(last_snap)` and DISCARDING outputs. This populates
       VPIN (50 buckets × 50k contracts), Kyle (100 trades),
       trade-imbalance (100 trades), OFI-stats (300 snapshots), and
       prev_snapshot to bit-identical sequential-walk state.
    3. Process core bars and emit ofi_per_bar rows.
- Concatenate shard outputs in order, then run post-loop passes
  (lag-1 deltas, log_bar_duration) over the full Vec — both are pure
  functions of the concatenated output / bar timestamps and have no
  shard interaction.

Bit-equivalence guarantee
=========================
Each shard's warmup phase replays the same prefix of trades+snapshots
into a freshly initialised local `OFICalculator`. Once the warmup has
covered enough bars to fully populate every rolling window AND any
post-warmup-window state has been carried forward, the shard's
calculator state at the warmup→core boundary is bit-identical to the
sequential walk. `MicrostructureState` is constructed fresh per-bar
(no cross-bar state) so its semantics are unchanged.

Verified by `crates/ml-features/tests/ofi_parallel_bit_equiv_test.rs`:

| test                                            | result | max abs diff |
|-------------------------------------------------|--------|--------------|
| parallel_matches_sequential_within_1e9_k4       | pass   | ≤ 1e-9       |
| parallel_matches_sequential_within_1e9_k8       | pass   | ≤ 1e-9       |
| parallel_k1_is_sequential                       | pass   | exactly 0    |
| parallel_handles_no_trades                      | pass   | ≤ 1e-9       |
| parallel_speedup_smoke (ignored, bench-shaped)  | n/a    | n/a          |

Speedup smoke (release, K=8, synthetic data):

  n=8000 bars : seq=16.36ms par=18.98ms speedup=0.86x
  n=30000 bars: seq=50.89ms par=29.56ms speedup=1.72x
  n=80000 bars: seq=142.12ms par=55.13ms speedup=2.58x

Speedup grows with N because the 5000-bar warmup overhead
amortises. At production scale (50-200k bars × ~3 snap/bar × ~5
trades/bar) real workloads will see closer to K-1.x speedup. Small
datasets (<10k) regress slightly — by design; warmup must be ≥
rolling-window depth for bit-equivalence and we will not relax that
for marginal wall-clock gains on tiny inputs.

Diff
====

- `crates/ml-features/src/ofi_calculator.rs` (+356 LOC):
    `compute_ofi_per_bar_sequential` (reference impl),
    `compute_ofi_per_bar_parallel` (rayon par_iter over shard ranges),
    `process_one_bar` (shared per-bar body — single source of truth
    for the loop semantics, no duplication between paths),
    `apply_post_loop_passes`, `PerBarOfiInputs` struct,
    `PER_BAR_OFI_DIM=32` const, `DEFAULT_OFI_PARALLEL_WARMUP_BARS=5000`
    const.
- `crates/ml-features/src/lib.rs` (+4 LOC): re-export new public API.
- `crates/ml/examples/precompute_features.rs` (-132 +35 LOC): replace
    the inline 132-line OFI loop with a call to
    `compute_ofi_per_bar_parallel`. Reads `OFI_SHARD_COUNT` env or
    falls back to `rayon::current_num_threads()`.
- `crates/ml-features/tests/ofi_parallel_bit_equiv_test.rs` (+~280 LOC,
    new): synthetic-data bit-equivalence tests at K=4, K=8, K=1, and
    no-trades.

Memory budget per shard: one cloned `OFICalculator` (≤10 MB carrying
the rolling-window state). K=8 ≈ 80 MB extra. Manageable on the 28-core
CPU node.

No `mbp10_to_imbalance_bars` / `filter_front_month_mbp10` changes
(out of scope; those were just fixed and a separate smoke is running).
No audit-doc update required: changes are confined to ml-features +
ml/examples and do not touch crates/ml/src/(cuda_pipeline|trainers/dqn)/
which is the trigger scope for the Invariant-7 audit-doc check.
2026-05-10 12:17:40 +02:00
jgrusewski
abd7e533bc fix(architectural): volume_bar_size in cache key + OFI front-month filter
## Two architectural cleanups, both surfaced by the wgdc8 experiment

### Part 1: volume_bar_size in cache key

Mirrors the imbalance_bar_threshold/ewma_alpha fix from `f7718b376`. The
volume bar size constant (100 contracts/bar) was previously hardcoded and
not in the fxcache key. Tuning it would have hit the same fossilization
bug as imbalance_bar_threshold did pre-fix.

Changes:
- `Hyperparams.volume_bar_size: u64` field added (default 100, matches
  `DEFAULT_VOLUME_BAR_SIZE` for backwards compat).
- TrainingProfile loader reads `volume_bar_size` TOML key.
- `calculate_dbn_cache_key_full` signature 7 → 8 args. Hashed via
  `to_le_bytes()`. Test `test_cache_key_includes_volume_bar_size`
  added; passes alongside the 5 existing tests.
- 4 callers updated atomically (per `feedback_no_partial_refactor`):
  `discover_and_load`, `data_loading.rs:146`, `train_baseline_rl.rs:599`,
  `precompute_features.rs:259,720`.
- `data_loading.rs:279` now passes `self.hyperparams.volume_bar_size`
  to `build_volume_bars` instead of the hardcoded `DEFAULT_VOLUME_BAR_SIZE`.
- New `--volume-bar-size` CLI arg on both binaries (default 100).
- New Argo workflow params `volume-bar-size` (default "100") and
  `data-source` (default "mbp10") on both `train-template.yaml` and
  `train-multi-seed-template.yaml`. Threaded into precompute + trainer
  invocations.
- `scripts/argo-train.sh` exposes `--volume-bar-size <n>` and
  `--data-source <s>` for ad-hoc overrides.

### Part 2: OFI front-month filter (latent bug fix)

`crates/ml/examples/precompute_features.rs:539-557` (the OFI/VPIN/Kyle's
Lambda computation branch when MBP-10 + trades data is available) was
loading trades unfiltered for per-bar microstructure feature computation.
The volume bar formation path filters front-month per-file (line 354), but
the OFI path did not.

Effect pre-fix: during contract rollover windows (e.g., ESZ24 → ESH25),
OFI per-bar microstructure features included trades from BOTH contracts
simultaneously, distorting VPIN, Kyle's Lambda, and trade imbalance
signals. Severity in production: small (front-month dominates ES.FUT
volume by 10-100×) but real and present in every prior MBP-10+trades
production run.

Fix: mirror the per-file `filter_front_month` call from the volume bar
path. Volume bar formation and OFI computation now both see the same
in-month trade tape. Added log line shows raw vs filtered count per file
for transparency.

## Why bundled

Both fixes touch trade-data plumbing in `precompute_features.rs` and the
fxcache key contract. Per `feedback_no_partial_refactor`, related
architectural cleanups land atomically. Both surfaced from the same
wgdc8 audit; bundling avoids two cache-key-invalidating commits in
sequence (each would force full fxcache regen).

## Compatibility

- `volume_bar_size` defaults to 100 → existing wgdc7-equivalent runs
  reproduce, but with a *new* fxcache key (the f7718b376-era cache file
  is unreachable; harmless, can GC manually).
- OFI fix is strictly more correct; no opt-out needed. Existing models
  trained on contaminated OFI features may show slight feature
  distribution drift on first cache regen — expected, not a regression.
- `data_source = "ohlcv"` Argo param now possible; routes precompute
  through volume bar branch directly. wgdc8 experiment uses this to test
  bar resolution sensitivity at volume_bar_size=500 (5× DEFAULT).

Tests: 6/6 feature_cache tests pass. Workspace + examples compile clean.
Audit-doc: `docs/dqn-wire-up-audit.md` updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:55:33 +02:00
jgrusewski
f7718b3761 fix(architectural): include bar formation params in fxcache key + actually USE imbalance bars
## The bug (audit 2026-05-09)

`crates/ml/src/feature_cache.rs:calculate_dbn_cache_key_full` hashed only
`(symbol, data_source, dbn_filenames+sizes)` — NOT `imbalance_bar_threshold`
or `imbalance_bar_ewma_alpha`. Combined with `precompute_features.rs:346`
unconditionally calling `build_volume_bars` regardless of `data_source`,
this meant:

1. 14 audited production runs (Apr 11–May 8) all collided on the same
   fxcache key (`a3f933aa...` / `c07c960a...`) regardless of TOML
   `imbalance_bar_threshold` value
2. The imbalance-bar code path was reachable only via fxcache MISS, which
   never happens in production because `ensure-fxcache` always populates
   first
3. Every "tuning" of `imbalance_bar_threshold` across 16+ SP runs was a
   silent no-op — the system was actually running volume bars at
   DEFAULT_VOLUME_BAR_SIZE (100 contracts/bar)

## The fix (this commit)

**Part A — cache key includes bar formation params:**
- `calculate_dbn_cache_key_full` signature: 5 args → 7 args. Two new f64
  params hashed via `to_le_bytes()`.
- 4 callers updated atomically (per `feedback_no_partial_refactor`).
- 2 new unit tests (`test_cache_key_includes_bar_threshold`,
  `test_cache_key_includes_bar_alpha`) pin the contract.

**Part B — precompute_features actually USES data_source:**
- New CLI args `--imbalance-bar-threshold` (default 0.5) and
  `--imbalance-bar-ewma-alpha` (default 0.1) on both train_baseline_rl
  and precompute_features.
- `precompute_features.rs:346` now branches: when
  `data_source == "mbp10"` AND `mbp10_data_dir.is_some()`, calls
  `mbp10_to_imbalance_bars` instead of `build_volume_bars`.

**Argo plumbing:**
- `train-template.yaml` + `train-multi-seed-template.yaml`: new workflow
  parameters threaded into BOTH precompute and trainer invocations so
  both compute the same fxcache key.
- `scripts/argo-train.sh`: new CLI flags for ad-hoc overrides.
- ensure-fxcache regen path: removed `rm -f /feature-cache/*.fxcache`
  (with bar-params now in key, parallel experiments coexist).

## Effects going forward

- Tuning `imbalance_bar_threshold` actually changes bar density
- Configuring `data_source = "mbp10"` actually produces imbalance bars
- Multiple parallel experiments at different thresholds coexist on PVC
- `dqn-production.toml: imbalance_bar_threshold = 0.5` no longer ignored

Default values match prior production behavior → existing wgdc7-equivalent
runs reproduce, just with a *new* fxcache key (the old volume-bar cache
file is still on disk but won't be hit; harmless, can GC manually).

Audit-doc: `docs/dqn-wire-up-audit.md` updated with full context.
Tests: 5/5 feature_cache tests pass, full workspace + examples compile clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:04:50 +02:00
jgrusewski
d39005c6f4 feat(sp19 commit b): producer-side multi-horizon reward blend at fxcache write time
Path (B) Commit B — load-bearing semantic change for the SP19 multi-
horizon reward augmentation. At fxcache-write time, blend 1-bar / 5-bar
/ 30-bar log-returns into `tgt[1]` (`preproc_next`) using equal-thirds
weights with `1/sqrt(N)` vol-scale correction:

    blended = (1/3) * r_1
            + (1/3) * r_5  / sqrt(5)
            + (1/3) * r_30 / sqrt(30)

The vol-scale correction applies INSIDE the blend (before weighting) so
each horizon's log-return is at unit-volatility-equivalent under
random-walk assumption — drops naturally into the existing `tgt[1]`
preprocessing pipeline (consumed in `experience_kernels.cu:1556` and
`cuda_pipeline/mod.rs:508`) without double-scaling.

Producer trim: valid range shrinks by `LOOKAHEAD_HORIZON_MAX = 30`
bars since `r_30` needs `close[i + WARMUP + 30]`. Walk-forward fold
construction is dataset-relative, so trimming at producer time shifts
every fold's tail by 30 bars — one-time cost per fxcache regen.

Both producer call sites (`precompute_features.rs` for the
feature-precompute binary, `data_loading.rs` for the DBN-fallback
in-process path) change atomically per `feedback_no_partial_refactor`.
Kernel consumers are unchanged — only `tgt[1]`'s value composition
differs from the consumer's perspective.

ISV slots [507..510) (allocated in Commit A) are NOT consumed at
producer time — `precompute_features` runs BEFORE training so the
ISV bus isn't initialised when the blend happens. Producer hardcodes
1/3 each via `SP19_HORIZON_BLEND_WEIGHTS`. Future Path (A) refactor
would bump TARGET_DIM to carry per-horizon log-returns and read
ISV at training-step time; for the empirical hypothesis test
("does multi-horizon blend lift WR?") equal-thirds is sufficient.

fxcache schema invalidation: `FXCACHE_VERSION` 8 → 9. Existing
`.fxcache` files (1-bar-only `preproc_next`) fail validation at load
and trigger Argo's ensure-fxcache regen step. First L40S run after
this commit takes ~10-15 min longer for the regen — one-time cost,
expected.

Touches:
- crates/ml/examples/precompute_features.rs: SP19_HORIZON_BLEND_WEIGHTS
  constant + LOOKAHEAD_HORIZON_MAX trim + blend in tgt[] writer +
  early-bail-out check on minimum dataset size.
- crates/ml/src/trainers/dqn/data_loading.rs: same blend +
  trim in DBN-fallback path; `last sample targets itself` block
  removed (the trimmed range guarantees feature-vector and target
  lengths match without a sentinel last row).
- crates/ml/src/fxcache.rs: FXCACHE_VERSION 8 → 9 + v9 docstring entry.
- crates/ml/tests/multi_horizon_reward_blend_test.rs (NEW): CPU-only
  oracle behavioural test — 4 cases including known returns, trim
  contract, zero-close short-circuit, constant-price blend.
- docs/dqn-wire-up-audit.md: Concerns subsection appended to the
  SP19 Commit B entry already drafted in Commit A (pre-existing
  test_fxcache_empty + test_dqn_checkpoint_round_trip flakiness
  documented for Invariant 7).

Verification:
SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo check --workspace        clean
cargo test -p ml --test multi_horizon_reward_blend_test              4/4 pass
cargo test -p ml --test fxcache_roundtrip_test test_fxcache_f32_roundtrip  passes (TARGET_DIM unchanged)
cargo test -p ml --lib ...                                           13 baseline failures (pre-existing); zero new regressions
bash scripts/audit_sp18_consumers.sh --check                         exit 0

Pre-existing test_fxcache_empty failure: hand-crafts a 64-byte header
but the actual header is 72 bytes; reader bails early on
"failed to fill whole buffer" instead of "bar_count=0". Test setup
bug, NOT a regression. Pre-Commit B failure count: 13. Post-Commit B
failure count: 13 (the test_dqn_checkpoint_round_trip test is flaky
and toggles independently of this change — verified by stashing and
re-running 3× pre-Commit B).

Atomic-refactor invariant satisfied: Commit A (slot reservations) +
Commit B (producer-side blend) land on the same branch with no L40S
dispatch between. Per task instruction the branch stays unpushed
pending user review.

DBN-fallback path: applied identically in `data_loading.rs:497-528`.
Both producers use the same `SP19_HORIZON_BLEND_WEIGHTS` constant and
the same `LOOKAHEAD_HORIZON_MAX = 30` trim.

Vol-scale correction site: applied inside the blend (before weighting)
in BOTH producers. The existing `tgt[1]` preprocessing pipeline does
NOT double-scale — `experience_kernels.cu:1556` and
`cuda_pipeline/mod.rs:508` consume `tgt[1]` as a unit-scale log-return
exactly as before the blend was added; the blended value drops in
without further scaling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:45:20 +02:00
jgrusewski
ce841eb56e fix(audit): lookahead housekeeping — purge gap + per-fold NormStats
Closes recs 2 + 3 in lookahead-bias-audit-2026-04-28.md.

Fix 2A (rec 2): add walk_forward::PURGE_BARS = 5 constant and insert
val_start = train_end + PURGE_BARS in all 4 fold-construction sites:
generate_walk_forward_indices, _from_timestamps, _windows (date-based,
drops first PURGE_BARS bars of val), and gpu_walk_forward::generate_folds
(both stratified variants preserve the gap on boundary shift). 5-bar
width matches max per-bar feature lookback (autocorr lags 1/5/10);
compile-time per feedback_isv_for_adaptive_bounds since the
feature-pipeline lookback is itself compile-time.

Fix 2B (rec 3): per-fold NormStats fit from train-only data.
- walk_forward.rs: add from_features_slice + denormalize/denormalize_batch
  helpers (inverse of normalize_batch for clamp-bounded round-trip).
- train_baseline_rl.rs fold loop: load fxcache sidecar norm_stats.json,
  denormalise back to RAW, refit per-fold via from_features_slice,
  renormalise full dataset, re-upload via init_from_fxcache, save
  per-fold norm_stats_fold{N}.json. DBN-fallback path keeps legacy
  behaviour (no sidecar to denormalise from) with a warn! log.
  Ensemble trainer block is documented follow-up (separate code path).

Behavioral tests:
- test_norm_stats_per_fold_fit_train_only: synthetic train-mean=1.0 /
  val-mean=9.0 dataset; from_features_slice recovers train-only mean to
  ε=1e-5 while from_features returns global 5.0
- test_norm_stats_denormalize_roundtrip: non-clamped features round-trip
  bit-identically
- test_walk_forward_purge_gap_indices_from_timestamps: every fold has
  val_start - train_end == PURGE_BARS
- test_fold_generation_basic (gpu_walk_forward): updated to expect the
  purge gap

Validation: cargo check --workspace clean (12.30s); 20/20 walk_forward
+ gpu_walk_forward tests pass; audit_sp18_consumers.sh --check exit 0.
Pre-existing test failures on local RTX 3050 Ti are unrelated SIGSEGVs
(VRAM exhaustion in chunked Thompson tests, pre-existing on baseline).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:52:00 +02:00
jgrusewski
81a9319d84 fix(sp15-wave3b-followup): migrate evaluate_baseline.rs 3 GpuBacktestEvaluator::new sites — Wave 3b missed the example binary
Wave 3b (4320820ae) added a 6th window_lob_bars parameter to
GpuBacktestEvaluator::new and migrated 3 production lib call sites + 6
test call sites, but the lib-only `cargo check -p ml --features cuda`
validation didn't catch the 3 example-binary call sites in
evaluate_baseline.rs (lines 1344, 1641, 1802). Argo's ensure-binary
step compiles all 4 example binaries (hyperopt_baseline_rl,
train_baseline_rl, evaluate_baseline, precompute_features), and
evaluate_baseline failed with E0061 (wrong number of args).

This commit migrates all 3 sites to construct zero-OFI LobBar SoA
inline (eval-path lacks per-bar OFI features — same degradation
pattern as the PPO hyperopt adapter Wave 3b D4 resolution; OFI-impact
term degrades to 0 while commission + half-spread × position still
apply).

Validated: `cargo check -p ml --features cuda --all-targets` clean
(was --features cuda only before — now expanded to catch
example/test/bin compile-breaks).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 10:25:50 +02:00
jgrusewski
ce019c72d2 feat(sp15-p1.6): --holdout-quarters + --dev-quarters CLI flags + sealed Q1-Q7/Q8/Q9 split
Per spec §6.6 / Q6 train/dev/test split (defaults Q1-Q7 train, Q8 dev,
Q9 sealed final test):

- DQNHyperparameters: holdout_quarters + dev_quarters (default 1+1)
- crates/ml/examples/train_baseline_rl.rs: --holdout-quarters /
  --dev-quarters CLI flags forwarded to hyperparams (this is the actual
  training binary; bin/fxt/src/commands/train.rs is a gRPC client and
  services/ml_training_service/src/main.rs accepts training params via
  proto not CLI — see audit doc note).
- DQNTrainer::train_walk_forward slices training_data BEFORE fold
  generation; folds run on Q1..Q(9 - holdout - dev) only.
- DQNTrainer struct: dev_features/dev_targets/holdout_features/
  holdout_targets fields stash trailing slices for end-of-training dev
  eval and the Phase 4.3 separate eval-only workflow.
- debug_assert sealed-slice guard catches future refactors that
  re-introduce holdout into the training path.

Per established Phase 1 precedent (1.1-1.5: kernel/state lands first,
consumer wiring deferred to follow-up commit per
feedback_no_partial_refactor): CLI plumbing + slicing + dev/holdout
storage land in this commit. The post-final-fold dev evaluation call
(consumer of dev_features) is deferred to a follow-up commit and will
mirror Task 1.7's evaluate_dqn_graphed integration pattern. Phase 4.3
argo-eval-final.sh is the sole legitimate consumer of holdout_features
(separate eval-only workflow that does NOT call train_walk_forward).

cargo check -p ml --features cuda --example train_baseline_rl: clean
cargo check -p fxt: clean (no fxt changes needed; gRPC client only)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:39:37 +02:00
jgrusewski
5845e44031 fix(data): DBN spread-instrument filter — root cause of Bug 2 contamination
Direct inspection of ES.FUT_2024-Q1.dbn.zst (databento python client, offline
kubectl-cp from PVC) pinpoints the source of the 8,799 corrupt bars that
sanitize_bars (Fix 25) caught at the bar-level gate.

Q1 file content:
  Outrights (correct): ESH4/ESM4/ESU4/ESZ4/ESH5  — 43,353 records (76.87%)
                       price range $5,063–$5,478
  Spreads  (poison):   ESH4-ESM4/ESM4-ESU4/...   — 13,048 records (23.13%)
                       price range $47.30–$219.70

Calendar spreads trade at the price DIFFERENCE between adjacent contracts
(~$60-150 cost-of-carry roll basis). Databento's stype_in=parent resolution
for ES.FUT returns BOTH outrights AND every spread combination in the same
DBN stream. The legacy decoder keyed only by ts_event + dedup-by-volume;
during low-volume overnight windows + active rollover periods, spread bars
beat the outright on volume and survived the dedup. 780 spread bars
survived in Q1 alone; ~8,800 across 2024-2026.

Fix: build dbn::TsSymbolMap from metadata once, resolve each record's
instrument_id → symbol, skip any symbol containing `-` (spread separator).
Same-ts dedup-by-volume continues to handle legitimate front/back-month
overlap among outrights.

Adds `time = "0.3"` to ml/Cargo.toml (dbn::TsSymbolMap uses time::Date).

Why complementary to the Fix 25 sanitize_bars gate:
  - Spread filter (this commit) catches the cause precisely by symbol pattern,
    but only for instruments where parent-symbol resolution is the source.
  - sanitize_bars catches the symptom universally by close-ratio bound, but
    can't distinguish a low-basis spread from a fast-moving outright in
    extreme cases.
  Both layers active: spread filter dispatches at parser level, sanitize as
  defense-in-depth backstop for unknown future contamination shapes.

Validation: `cargo check -p ml --example train_baseline_rl` clean.

Refs: Bug 2 chain (#191, #194, label_scale=5443 leaks), today's
sanitize_bars find (Fix 25). Closes the contamination-source investigation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:51:53 +02:00
jgrusewski
8434737a69 fix(data): structural defense at data-loading boundary — 5 layers
Stops chasing one-off corruption bugs. Three+ historical fixes patched
specific writers (#191 fxcache column-0, Bug 1 target schema,
label_scale=5443 leaks, today's state[0] heavy-tail). Each new
corruption shape found the next hole. This installs a structural
defense so corruption is REJECTED at the data-loading boundary
regardless of source.

Five independent layers, each mandatory:

1. Bar-level sanity (baseline_common::sanitize_bars):
   drop bars with non-positive OHLC, high<low, non-finite values, or
   close/prev_close outside [0.5, 2.0]. Catches DBN parse glitches,
   broker tick errors, near-zero-open bars at source.

2. safe_log_return result clamp (extraction.rs:1246):
   ratio.ln().clamp(±0.1). Real-market 1-bar log returns rarely
   exceed ±0.05; ±0.1 traps every legitimate move while rejecting
   the corruption shape (corrupt bar with bar.open ≈ 0 → ln = -30
   → previously normalized to -30000-magnitude state[0] outliers).

3. validate_features pre-norm bound (extraction.rs:538):
   |val| ≤ 5.0 post-extraction. Pre-norm features come from
   safe_normalize ([0,1]/[-1,1]), safe_clip (max ±3), or clamped
   log-returns (±0.1); ±5 catches extractor invariant breaks.

4. NormStats::normalize post-norm clamp (walk_forward.rs:688):
   ((val - mean) / std).clamp(±20.0). Even if upstream produces
   outliers, every value uploaded to GPU is bounded.

5. Shared validate_normalized_features gate (walk_forward.rs):
   single source-of-truth invariant enforced at THREE sites:
     - fxcache fast path (after discover_and_load)
     - DBN fallback (after normalize_batch)
     - precompute writer (before fxcache write — never persist
       a poisoned cache)

Removed: DIAG_BUG2 + DIAG_BUG2_v2 one-shot diagnostics
(~125 lines of host-side download + outlier scan in
training_loop.rs). Replaced by structural defense — instrumentation
isn't needed when corruption can't reach state[0].

FEATURE_SCHEMA_HASH auto-bumps via build.rs FNV-1a hash over
SCHEMA_FILES (extraction.rs included). All pre-fix .fxcache files
on PVC are invalidated at load time; ensure-fxcache regen produces
clean cache with new clamps applied.

Why this finally closes the chapter: per-writer fixes are reactive
(land after corruption hits prod). Boundary validation is
proactive — every future regression to extraction or normalization
trips the gate at load, not at epoch 5 of a 50-epoch run. The 5
layers are independent: a bug in any one leaves the others as
backstop.

Validation: cargo check -p ml --all-targets --offline clean.
NormStats unit tests (walk_forward.rs:706+) still pass — clamp +
validate are additive; existing test inputs are well within bounds.

Refs: SP5 Bug 2 (state[0] std=570 outliers on smoke-test-xb78r),
historical #191 #210 #214 #193 #195 chains.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:02:53 +02:00
jgrusewski
5a5dd0fed1 fix(fxcache): target column [0:1] log-return-normalized, not raw price
Bug 1 of the eval-Hold-collapse diagnosis. The fxcache target schema
documented in experience_kernels.cu:1556 + cuda_pipeline/mod.rs:508 specifies:

  target[0] = preproc_close  — log-return-normalized close (network input)
  target[1] = preproc_next   — log-return-normalized next close
  target[2] = raw_close      — raw price for portfolio simulation
  target[3] = raw_next       — raw price
  target[4] = raw_open       — raw price
  target[5] = mid_price_open — MBP-10 midpoint (fallback raw_open)

Both writers — `precompute_features.rs:360` and `data_loading.rs:510` —
violated the contract by storing raw OHLCV close prices in slots [0:1].
Empirical fxcache inspection: target[0..4] mean=$5967, stddev=$582 (raw
prices throughout). The raw-price values at target[0:1] were never directly
consumed by training (production aux head reads next_states[i][0] = MARKET
feat[0] = log_return), but they corrupted any code reading targets per the
documented contract.

Both writers now compute (raw_curr / prev_close).ln() and (raw_next /
raw_curr).ln() for the preproc columns. FXCACHE_VERSION bumped 7→8 to
invalidate existing caches and trigger ensure-fxcache regen.

A second bug — eval label_scale=5300 (raw price magnitude) at production
binary despite source state[0] tracing back to z-normalized log_return —
remains unresolved. Bug 2 instrumentation lands in the next commit; that
runtime trace will pin which production-binary code path injects raw_close
into state[0] post-gather.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:36:49 +02:00
jgrusewski
58ffb3a48e feat(sp4): Layer B — atomic consumer migration to ISV-driven bounds
Single coordinated commit per `feedback_no_partial_refactor`. All
SP3-era hardcoded magnitude multipliers (10×, 100×, 1e3×, 1e6×) and
ε floors (.max(1.0)) replaced by per-slot ISV reads with consumer-side
EPS_CLAMP_FLOOR=1.0 numerical safety.

Mechanism mapping:
- Mech 1 target_q clamp: 10 × Q_ABS_REF.max(1.0) → ISV[TARGET_Q_BOUND]
- Mech 2 atom-position clamps (3 sites × 4 branches): 10 × Q_ABS_REF
  → ISV[ATOM_POS_BOUND[branch]]
- Mech 5 fused diagnostic: per-slot ISV reads in
  `dqn_nan_check_fused_f32_kernel` (kernel takes `isv_signals*` instead
  of `q_abs_ref_eff` / `h_s2_rms_ema_eff` host args)
- Mech 6 adaptive_clip upper_bound: 100 × slow_ema × Q_ABS_REF
  → ISV[GRAD_CLIP_BOUND]
- Mech 9 post-Adam weight_clamp (5 Adam kernels): 100 × Q_ABS_REF
  → ISV[WEIGHT_BOUND[group]]
- Mech 10 h_s2 clamp: 100 × H_S2_RMS_EMA → ISV[H_S2_BOUND]
- AdamW weight_decay (5 kernels): config field → ISV[WD_RATE[group]]
- L1 lambda (trunk only): 1e-3 → ISV[L1_LAMBDA_TRUNK_INDEX]

DQN main Adam split into 3 per-group sub-launches (DqnTrunk / DqnValue /
DqnBranches) per `feedback_no_quickfixes`. Overrides the plan's
"max/min-of-3 single-launch shortcut" recommendation. Each sub-launch
reads its own WEIGHT_BOUND[group], WD_RATE[group], and (trunk only)
L1_LAMBDA_TRUNK_INDEX. Pearl C engagement-counter deferral from
A14/A15 resolved in this same commit — per-group split means each
sub-launch writes per-block counts at its own offset, and
`pearl_c_post_adam_engagement_check` is invoked per group from
fused_training.rs (DqnTrunk/DqnValue/DqnBranches separate calls).

`weight_decay` field removed from:
- DQNHyperparameters (crates/ml/src/trainers/dqn/config.rs)
- GpuDqnTrainConfig (crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs)
- GpuIqnConfig (crates/ml/src/cuda_pipeline/gpu_iqn_head.rs)
- GpuIqlConfig (crates/ml/src/cuda_pipeline/gpu_iql_trainer.rs)
- TrialOverrides + PSO search-space (crates/ml/src/training_profile.rs)
- apply_family_scaling (`weight_decay *= li` line removed)

Aux trainers outside SP4 8-group taxonomy (DT, ofi_embed, denoise,
sel/recursive_conf) keep `weight_clamp_max_abs = 0.0` disable —
mirrors the existing DT pattern. They have no individual ISV producer,
so they don't read SP4 bounds.

Files-touched (17): atoms_update_kernel.cu, iql_value_kernel.cu,
experience_kernels.cu, dqn_utility_kernels.cu, gpu_dqn_trainer.rs,
gpu_iqn_head.rs, gpu_iql_trainer.rs, gpu_attention.rs, gpu_tlob.rs,
fused_training.rs, training_loop.rs, constructor.rs, config.rs,
generalization.rs (smoke), training_profile.rs, train_baseline_rl.rs,
dqn-wire-up-audit.md.

Verification (local, RTX 3050 Ti):
- `cargo check -p ml --offline`: clean.
- `git grep -nE "10\.0_f32 \* q_abs_ref|10\.0f \* q_abs_ref|100\.0_f32
  \* q_abs_ref|100\.0f \* q_abs_ref|1e6_f32 \* q_abs_ref|1e3_f32 \*
  q_abs_ref|100\.0_f32 \* h_s2|100\.0f \* h_s2_rms" crates/ml/src/`:
  ZERO matches.
- `git grep -nE "weight_decay:\s*f64|l1_lambda:" crates/ml/src/trainers/dqn/`:
  ZERO matches.
- `git grep -n "self.config.weight_decay" crates/ml/src/`: only TFT
  remains (separate trainer outside SP4 scope).
- `git grep -n "q_abs_ref_eff|h_s2_rms_ema_eff"
  crates/ml/src/cuda_pipeline/dqn_utility_kernels.cu`: ZERO matches.
- 8 SP4 lib tests pass (sp4_wiener_ema, sp4_isv_slots,
  state_reset_registry).
- 14 SP4 producer GPU tests pass on RTX 3050 Ti (no behavior change at
  producer level — consumer-side migration only).
- `cargo test -p ml --lib --offline`: 928 passed, 14 failed (all 14
  pre-existing on HEAD `1389d1c81`; no new failures).

Validation deferred to Layer C smoke. Expected: F0/F1/F2 all complete
5 epochs; F1 trains past step 1000; F0 ≥ 37.5; F2 ≥ 55; slot 49 quiet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:10:41 +02:00
jgrusewski
aa56cd7069 fix(ofi): align OFI window to bar t's formation interval — kill 31/32-slot lookahead
OFI features at bar t were computed over [close(bar_t), close(bar_{t+1}))
— bar t+1's formation period — so 31 of 32 OFI dims at the policy's
input for bar t carried next-bar microstructure data. Per audit
`docs/lookahead-bias-audit-2026-04-28.md` §3, this is a DEFINITE leak
contaminating both training inputs and validation backtest.

Fix: shift the window backward to (close(bar_{t-1}), close(bar_t)] so
OFI at bar t uses only data accumulated during bar t's own formation.
Mirrors the correct `log_bar_duration` convention at
precompute_features.rs:567-575 (audit's smoking-gun citation).

Per feedback_no_partial_refactor, both write sites migrated in lockstep:
- crates/ml/examples/precompute_features.rs:441-475 (precompute path)
- crates/ml/src/trainers/dqn/data_loading.rs:396-448 (DBN fallback)

FXCACHE_VERSION bumped 6 → 7. PVC auto-regen via schema-hash check on
next deploy. Local regen:

  ./target/release/examples/precompute_features \
      --data-dir test_data/futures-baseline \
      --mbp10-data-dir test_data/futures-baseline-mbp10 \
      --trades-data-dir test_data/futures-baseline-trades \
      --output-dir test_data/feature-cache \
      --symbol ES.FUT --data-source mbp10 --yes

Regression test added: tests/ofi_features_test.rs::
test_ofi_window_alignment_uses_formation_interval validates the new
partition_point predicates against a synthetic 5-bar dataset, including
an explicit anti-regression assertion that bar t+1 snapshots are NEVER
picked up for bar t.

dqn-wire-up-audit.md updated to reflect new contract for
`trainers/dqn/data_loading.rs`. Audit report
`docs/lookahead-bias-audit-2026-04-28.md` checked in alongside the fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:23:32 +02:00
jgrusewski
366832be44 refactor(data_source): default to mbp10 across smoke + localdev profiles
Smoke and localdev profiles previously used data_source = "ohlcv", divergent
from production (mbp10). Mismatch silently produced cache-key collisions in
the SHA256 hash path: smoke fxcache could not be loaded by production-shape
training without an explicit override. Local-dev fxcache regen also required
remembering to pass --data-source ohlcv to match.

Globalize mbp10 as the default everywhere it isn't deliberately overridden:
- config/training/dqn-smoketest.toml: data_source = "mbp10"
- config/training/dqn-localdev.toml: data_source = "mbp10"
- training_profile.rs: doc Default → "mbp10"
- trainers/dqn/config.rs: Default impl → "mbp10"
- hyperopt/adapters/dqn.rs: default → "mbp10"
- examples/precompute_features.rs: doc updated
- fxcache.rs / feature_cache.rs: discover_and_load + cache-key tests
  use "mbp10" arguments
- docs/dqn-wire-up-audit.md: new entry per Invariant 7

Documentation strings retained "ohlcv" only where they document the two
available choices (config.rs:946, training_profile.rs:83).

Local fxcache regenerated to v6 mbp10:
test_data/feature-cache/13c0b086a975cc7e2384377a2cd0e97738c9410292fcfecb5807c29bf885cb48.fxcache
(175874 bars, 55 MB, OFI_DIM=32). Stale v5 ohlcv fxcache untracked
from git index (already gitignored post-79578bbaf).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:35:47 +02:00
jgrusewski
da632446ce refactor(dqn): strip use_iqn feature flag + dead legacy iqn_network
`use_iqn` is exactly the `use_/enable_` boolean banned by
`feedback_no_feature_flags.md`. It gated dead code: production training
runs IQN unconditionally through `cuda_pipeline/gpu_iqn_head.rs` +
`iqn_dual_head_kernel`, properly wired into the branching architecture
with the `FIXED_TAUS` 5-quantile schedule. Nothing in the cuda_pipeline
production path ever read `use_iqn` or `iqn_network`.

The legacy `iqn_network: Option<QuantileNetwork>` field on `DQN` was a
parallel CPU-side network from a pre-branching era, structurally
unreachable in production: every consumer was gated behind the
`if true /* use_branching: always on */` arm at `q_values_for_batch`,
so the IQN else-if at L1822 was dead code. Training optimised
`iqn_network` parameters in isolation; inference never read them. That's
the train/inference mismatch the L283 comment ("IQN trains base
q_network but inference uses dist_dueling network (zero gradients)")
was working around by **disabling** the feature instead of fixing the
inference path. Per `feedback_no_quickfixes.md` + `feedback_no_hiding.md`
the fix is to remove the dead path entirely.

Strip:
- `DQNConfig::use_iqn` field + parses + checkpoint hash + metadata.
  `dqn.use_iqn` / `dqn.iqn_embedding_dim` / `dqn.iqn_num_quantiles` /
  `dqn.iqn_kappa` from older checkpoints are silently dropped on load
  (same pattern used for `use_dueling`). `iqn_lambda` stays — the
  cuda_pipeline dual head consumes it as the IQN aux loss weight.
- 3 vestigial config fields (`iqn_num_quantiles`, `iqn_kappa`,
  `iqn_embedding_dim`) — never read in production; kernel-side macros
  (`IQN_NUM_QUANTILES = 5`, embed_dim 64, kappa 1.0) are the actual
  config.
- 4 default builders (`Default`, `aggressive`, `conservative`,
  `emergency_safe_defaults`) drop the 4 IQN-related fields each.
- `DQN::iqn_network` field + initialisation block in `new_with_stream`.
- 6 conditional gates in `select_action`, `select_action_with_confidence`,
  `select_action_inference`, `q_values_for_batch` — all collapse to the
  live (branching or standard-Q) arm.
- `DQN::get_state_embedding` (only consumed by deleted IQN paths).
- The entire `crates/ml-dqn/src/quantile_regression.rs` module (392 LOC)
  + its 2 lib.rs exports. Nothing outside `ml-dqn` ever imported it
  (the `quantile_huber_loss` reference in `gpu_iqn_head.rs` is a CUDA
  kernel name string, unrelated to this Rust module).

Downstream call sites:
- `crates/ml/src/trainers/dqn/{config,fused_training,trainer/constructor}.rs`:
  drop `iqn_num_quantiles` / `iqn_embedding_dim` / `iqn_kappa` references
  off `DQNConfig`; substitute kernel-fixed literals (64, 1.0) where
  `GpuDqnTrainConfig` / `GpuIqnConfig` still expect them.
- `crates/ml/examples/evaluate_baseline.rs`: drop two `iqn_num_quantiles`
  hyperparam reads (their `..DQNConfig::default()` fallbacks now stand
  alone).
- `crates/ml/tests/dqn_action_collapse_fix_test.rs`: drop the
  `assert!(!config.use_iqn, "...gradient dead zone")` and the explicit
  `config.use_iqn = false` setter; the dead-zone pathology is now
  structurally impossible.
- `crates/ml/tests/dqn_inference_test.rs`: drop `config.use_iqn = false`.
- `services/trading_service/src/services/dqn_model.rs`: drop the
  `iqn={}` debug-log field.
- `crates/ml/src/trainers/dqn/distributional_q_tests.rs`: ship Test 0.F
  (Plan A Task 8 #186) — converged-checkpoint extraction harness +
  `MappedF32Buffer` (mapped pinned f32 mirror) + `compute_sigma_c51_test`
  kernel handle. The structural assertions panic on the local 5-epoch
  smoke checkpoint as designed (under-converged: sigma_C51 spread ~1.4%
  across directions, P(active)=0.4330 >= 0.20). Docstring rewritten to
  drop `use_iqn=false` framing and the legacy "Tier-B-prime" caveat;
  Tier-A version is GPU-integration-only per
  `feedback_no_cpu_forwards.md` (CPU is read-only).

`docs/dqn-wire-up-audit.md` updated per Invariant 7.

Build: `cargo check --workspace --tests` clean (0 errors).
Test: `cargo test -p ml --lib distributional_q_tests::test_0f
       -- --ignored --nocapture` produces bit-identical sigma_C51 /
       argmax / Thompson values vs pre-removal — confirms branching
       forward path is the same code post-cleanup as pre-cleanup
       (legacy `use_iqn` arms were unreachable, as expected).

Net: 12 files, +458 / -785 lines (327 LOC deleted).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:12:13 +02:00
jgrusewski
db9936b9ff fix(data): align fxcache data_source + normalise DBN fallback
Two-part fix for a class of bugs causing un-normalised features to
silently flow into training.

(1) data_source mismatch between precompute writer and trainer reader.

  precompute_features.rs:214,633 hardcoded "ohlcv"
  train_baseline_rl.rs:582       hardcoded "ohlcv"
  config/training/dqn-production.toml: data_source = "mbp10"

  Production runs the trainer with the production profile (data_source
  = mbp10), but the actual cache lookup hardcoded "ohlcv". Smoke worked
  by accident (smoke profile is also "ohlcv"). Any future profile with
  a different data_source silently mismatches → cache MISS → DBN
  fallback path.

  Both call sites now hardcode "mbp10" (the canonical production data
  source per CLAUDE.md). precompute_features adds a `--data-source`
  CLI override for the rare case a smoke flow needs to regenerate
  the local "ohlcv" fxcache; default is "mbp10".

(2) DBN-fallback path didn't normalise features.

  precompute_features.rs:629 applies NormStats::normalize_batch on the
  canonical fxcache write path. The fallback in train_baseline_rl.rs
  (cache-miss → load DBN files → extract features → upload to GPU) did
  NOT normalise. Any cache miss (data_source drift, schema-hash mismatch,
  missing file) silently uploaded RAW features. Raw close prices
  (~$5180 ES futures) flowed into next_states[:, 0]; the aux next-bar
  head's label_scale EMA latched onto raw-price magnitude (~5443 vs
  expected ~1.0 z-score); the shared trunk learned to predict next-bar
  prices; the policy effectively traded with future-price knowledge →
  train-h5gxb epoch-0 Sharpe = 141 with 0.32% max-drawdown over 214k
  bars (impossibly good = oracle leak).

  DBN fallback now applies the same z-score normalisation unconditionally
  as defence-in-depth, so a future cache-miss cannot reintroduce raw
  values into training.

Audit entry updated.
2026-04-27 14:21:42 +02:00
jgrusewski
fcf76701f4 plan5(task5-B): pivot multi-seed Argo from N×K (seed,fold) to N seed-only fanout
The first L40S deploy attempt (workflow `train-multi-seed-z2llf`, terminated)
failed at startup with `error: unexpected argument '--fold' found` on every
job: `train_baseline_rl` is a multi-fold walk-forward executor that accepts
`--max-folds K`, NOT `--fold N`. The original P5T1 harness assumed the
opposite and fanned out N seeds × K folds = N*K jobs, each invoking the
binary with `--seed N --fold K`.

User chose Path B: pivot to one job per seed (each runs all K folds via the
existing `--max-folds` mechanism). Per-job runtime is K× longer, but fanout
drops from N*K=30 → N=5 (matches L40S pool capacity better) and the binary
contract becomes the one the binary actually has.

4 surface changes:

1. crates/ml/examples/train_baseline_rl.rs — add `--seed N` CLI arg
   (default 42 — historic implicit value). Sets `FOXHUNT_SEED` env var at
   startup BEFORE any CUDA module spins up. Logs the seed value at the
   training start banner.

2. crates/ml/src/cuda_pipeline/mod.rs — add `global_seed()` (reads
   `FOXHUNT_SEED`, default 42) + `mix_seed(base)` (SplitMix64 avalanche
   so adjacent global seeds produce uncorrelated module seeds). Six call
   sites updated to mix the global seed into their previously-hardcoded
   constants:
   - trainer/action.rs: GpuActionSelector seed (0xDEAD_BEEF_CAFE) + the
     epsilon-greedy fallback StdRng (0xAC7_DEF0).
   - cuda_pipeline/gpu_iqn_head.rs: IQN Xavier-init RNG (0x1CA_1234).
   - cuda_pipeline/gpu_iql_trainer.rs: V(s) Xavier-init RNG (0x1C1_9ABC).
   - cuda_pipeline/gpu_her.rs: random-donor RNG (0x4E4_5678).
   - cuda_pipeline/gpu_ppo_collector.rs: rng_seeds Vec for PPO
     experience-collector init + reset (0xAA0_5EED).
   - trainer/training_loop.rs: per-epoch regime_dropout_seed.

3. infra/k8s/argo/train-multi-seed-template.yaml — drop `fold` parameter
   from `train-single` template; binary invoked as `--seed "$SEED"
   --max-folds {{workflow.parameters.folds}}` so the walk-forward sweep
   happens inside the single training process. Drop `FOLD` env var. Update
   the nsys-rep upload filename to drop the fold suffix. Update banners /
   doc comments to reflect "one-job-per-seed" semantics.

4. scripts/argo-train.sh — matrix generator drops the inner fold loop.
   Each emitted task carries only `seed=${s}` and depends on the same
   ensure-fxcache + gpu-warmup. The dry-run synthetic marker switches from
   `seed=${s} fold=${f}` to `seed=${s} max_folds=${FOLDS}` so test harnesses
   count the new shape correctly.

5. scripts/tests/test_multi_seed_harness.sh — assertions updated:
   - `--multi-seed 3 --folds 2` produces 3 tasks (was 6).
   - Rendered binary command must include `--max-folds
     {{workflow.parameters.folds}}` placeholder.
   - Rendered template must declare `folds` workflow parameter (so
     `argo submit -p folds=K` overrides the default).
   - Rendered binary command must NOT contain any per-fold flag — this
     catches the failure mode that broke the first L40S deploy.
   - Backward-compat: `--multi-seed 1 --folds 1` preserves the existing
     single-template path (no DAG matrix tasks emitted).

6. docs/dqn-wire-up-audit.md — adds 1 Wired row documenting the pivot,
   the new `--seed`/`mix_seed` plumbing, all 6 RNG call sites, and the
   end-to-end seed-variation verification result.

Validation:

  cargo check --workspace clean at 11 warnings (workspace baseline preserved).

  cargo build --release --example train_baseline_rl succeeds; --help shows
  the new --seed flag with documented default 42.

  Seed-variation end-to-end test on RTX 3050 Ti (1 fold × 2 epochs each):
    --seed 42  → F0 best Sharpe = -9.7831, best_val_metric = 1.957244,
                 epoch-2 train Sharpe = -16.12, val_Sharpe = +1.11.
    --seed 999 → F0 best Sharpe = +92.9341, best_val_metric = 2.161012,
                 epoch-2 train Sharpe = +92.93, val_Sharpe = -0.25.
  Different best Sharpe / best_val_metric / epoch-2 train + val Sharpe
  across seeds proves the seed actually propagates through the RNG init
  paths and is not just accepted-and-ignored. The seed=42 numbers match
  the prompt's "deterministic baseline" expectation (F0 = -9.7831 was
  bit-identical pre-pivot because no global-seed plumbing existed).

  ./scripts/argo-train.sh dqn --multi-seed 5 --folds 6 --dry-run produces
  exactly 5 WorkflowTask markers (train-s0..train-s4), each with
  `--max-folds {{workflow.parameters.folds}}` in the binary invocation.

  All 3 harness tests PASS:
    - test_multi_seed_harness.sh: 5 PASS lines, exit 0.
    - test_nsys_harness.sh: 4 PASS lines + ALL PASS, exit 0.
    - test_tier_checks.sh: PASS overall (good-fixture passes, bad-fixture
      surfaces expected check rejections), exit 0.

Backward compat: existing single-job `argo-train.sh` callers (no
`--multi-seed`, no `--folds`) route to the original `train-template.yaml`
unchanged. `--seed 42` is a no-op offset for the SplitMix64 mix at the call
sites — the trajectory shifts only when the user passes `--seed` explicitly,
matching the prompt's "default 42 (historic implicit value)" requirement.

L40S pool: argo-train.sh defaults `--gpu-pool ci-training-h100`; user passes
`--gpu-pool ci-training-l40s` at deploy time. No script default change
(per constraint 5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:11:32 +02:00
jgrusewski
536eea20bf fix(dqn-v2): local-smoke fallout — StateResetRegistry dispatch arms, controller_activity thresholds, example hp_f32 helper
Local smoke-test run after Plan 1 C.6 completion surfaced three issues:

1. StateResetRegistry missing dispatch arms for the 8 ISV slots
   pre-allocated in ac9bcab94 (isv_epoch_idx, isv_epsilon_eff, isv_tau_eff,
   isv_gamma_eff, isv_kelly_cap_eff, + 3 SchemaContract which already no-op).
   Fold boundary would error "unknown name 'isv_epoch_idx'". Added dispatch
   arms in training_loop.rs::reset_named_state — reset each to 0.0; GPU
   kernels repopulate on next epoch.

2. controller_activity smoke test's single 50% threshold was designed for
   reactive CPU-compute controllers. Under GPU-drives-CPU-reads, tau is a
   Polyak-EMA cosine schedule that fires every epoch by design (95%),
   gamma is health-coupled monotonic (may fire every epoch as health
   drifts). Split threshold per-controller: reactive (anti_lr, grad_clip,
   cql_alpha, cost_anneal) = 0.50; schedule-based (tau, gamma) = 1.00.

3. examples/train_baseline_rl was broken since the f64→f32 ABI refactor
   (d64adc14f) — hp_f64 returning f64 assigned to f32 fields. Added
   hp_f32 helper that narrows JSON-born f64→f32 at ingest boundary. Use
   hp_f32 for f32 fields, hp_f64 for f64 fields (learning_rate,
   entropy_coefficient, weight_decay). No more "as f32" casts at call
   sites. Also fixed replay_buffer_vram_fraction + bars_per_day f64→f32.

Local smoke tests now pass:
- controller_activity: ok (1 passed, 29.5s)
- multi_fold_convergence: ok (1 passed, 3 folds x 20 epochs, 534.9s)
- 24 new monitor + registry unit tests: all passing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:58:13 +02:00
jgrusewski
79578bbaf6 feat(fxcache): OFI_DIM 20→32, persist 12 real-math microstructure signals,
fix kernel-read gap

Adds 12 features to the DQN input pipeline:
 - 10 MicrostructureState::snapshot()[0..10] slots that were previously computed
   every bar and then discarded before reaching fxcache: ofi_trajectory,
   realized_variance, hawkes_intensity, book_pressure (weighted 10-level),
   spread_dynamics, aggression_ratio, queue_depletion_asymmetry,
   order_count_flux, intra_bar_momentum, regime_score.
 - 2 TLOB-novel slots derived directly from Mbp10Snapshot:
   order_count_imbalance = (Σbid_ct − Σask_ct) / Σ(bid_ct + ask_ct),
   microprice_residual = (weighted_mid − mid) / mid.

Also fixes a production gap: ofi_acceleration (slot 18) and
toxicity_gradient (slot 19) were persisted to fxcache via OFI_DIM=20
but the OFI embed kernel (experience_kernels.cu:6146-6173) only read
[0..18), silently discarding them every bar. Kernel extended to
consume full SL_OFI_DIM=32.

Dimension bumps (all 8-aligned):
  OFI_DIM          20 → 32
  FXCACHE_VERSION   4 → 5  (invalidates existing caches; regen via
                            precompute_features)
  STATE_DIM        96 → 104
  PADDING_DIM       4 → 0  (OFI expansion consumed padding, still 8-aligned)
  STATE_DIM_PADDED 128 (unchanged)
  OFI_EMBED_IN     18 → 32 (MLP input width; W/grad/Adam/m/v buffers
                            resized in lockstep via named constants)

fxcache regen results (175874 bars ES.FUT 2024-Q1):
  deltas_nonzero:       175781 / 175874  (99.9 percent)
  book_aggression:      102137 / 175874  (58.1 percent)
  microstructure[20-30): 175874 / 175874 (100 percent)
  tlob_novel[30-32):    133615 / 175874  (76.0 percent)

Compile status: SQLX_OFFLINE=true CARGO_INCREMENTAL=0 cargo check
  --workspace --tests passes cleanly (0 errors, pre-existing warnings
  only).

Test results:
  fxcache roundtrip (unit + integration): PASS (4+6 tests)
  magnitude_distribution smoke: ran through epoch 1 successfully
    (OFI_DIAG fires, state_dim=104 confirmed, feature_dim=74 in
    validation kernel); epoch 2 OOM on local RTX 3050 Ti (4 GB) —
    expected hardware limit from state_dim growth. Full 20-epoch run
    requires L40S/H100 CI verification.
  multi_fold_convergence smoke: not verified locally (same VRAM
    ceiling applies). L40S/H100 CI verification required.

The new slots follow the existing OFICalculator/MicrostructureState
pattern and consume signals already computed by ml-features — no new
crate, no ONNX, no stubs. All 12 sources were audited against their
implementation before persistence; every slot traces back to real
Mbp10Snapshot or MicrostructureState math.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:36:34 +02:00
jgrusewski
c071489979 infra(smoke): --max-bars cap for train_baseline_rl + multi_fold smoke
Adds an optional --max-bars CLI cap to `examples/train_baseline_rl.rs`.
When >0, truncates the fxcache-loaded features/targets/OFI/timestamps in
lockstep per the data_loading.rs precedent (commit 2ac956298 OFI/bars
desync fix), then rebuilds FxCacheData with the capped bar_count. 0 =
unlimited, matching pre-change production behaviour.

Wires --max-bars=500000 into the multi_fold_convergence smoke test. Full
ES.FUT fxcache is ~697,732 bars (~290 MB on GPU with OFI per the
session_2026-04-20 memory note); the smoke's 3-fold × (6+2+2+2×step =
14-month total) walk-forward span needs ~350k bars at 1-min resolution,
so 500k gives ~43% headroom while freeing ~81 MB of VRAM — enough to
unblock RTX 3050 Ti 4 GB local runs that OOMed before.

Applied after fxcache load and before WalkForwardConfig so downstream
fold-range generation sees the capped range. Zero effect on production
training (default 0 = unlimited).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:23:29 +02:00
jgrusewski
f4ea0f6d13 test(policy-quality): Task 0.16 surrogate_noise_check smoke (mandatory gate)
Adds evaluate_baseline CLI args:
  --surrogate-mode=off|random
  --surrogate-seed=<u64>
  --surrogate-marginals=<path.json>
  --emit-action-marginals
  --emit-pooled-sharpe

Random-action surrogate samples actions from marginal distribution (or
uniform fallback) using a seeded RNG, bypassing the model entirely.
Surrogate mode forces the CPU DQN path so action selection can be
overridden (GPU path would need invasive kernel changes and defeats
the bypass-the-model sanity check).

Flat action-index counts are tracked in the CPU DQN path only; the
ACTION_MARGINALS: line emits those as a JSON distribution, or emits
an honest stub marker when only the GPU path was exercised. Pooled
Sharpe is computed from concatenated per-fold returns (CPU path) or
falls back to mean-of-fold-Sharpes with a warning.

Smoke test runs 30 surrogate seeds + 1 trained run via evaluate_baseline
subprocess, asserts trained pooled Sharpe exceeds the 95th percentile of
surrogate Sharpes. Test is #[ignore]'d and requires a trained checkpoint
at /workspace/output/dqn_fold0_best.safetensors (Phase 3 deliverable);
FOXHUNT_SURROGATE_CKPT env var overrides for local testing. Uses the
compiled target/release/examples/evaluate_baseline if present, otherwise
falls back to cargo run.

Will pass once Phase 3 produces a checkpoint.
2026-04-21 23:22:27 +02:00
jgrusewski
4e1a937cec feat+test(policy-quality): Task 0.15 — multi_fold_convergence smoke + --max-folds
Adds --max-folds CLI flag to train_baseline_rl (0 = no cap). When set,
truncates the generated walk-forward fold list to the first N folds.
Used by the new multi_fold_convergence smoke test to run a 3-fold × 20-epoch
local variant of the Phase 3 L40S 6-fold × 50-epoch validation gate
(~5 min on RTX 3050 vs ~1 hour on L40S).

Test asserts:
  * train_baseline_rl subprocess exits 0 (no NaN/Inf)
  * ≥ 2/3 folds produce dqn_fold{N}_best.safetensors checkpoint
    (checkpoint is only written when Best Sharpe improves during the
    fold, so presence = policy learned something on that window)

Per plan Task 0.15 at docs/superpowers/plans/2026-04-21-policy-quality.md.
2026-04-21 21:37:01 +02:00
jgrusewski
f988ca384c fix(train): emit per-fold norm_stats.json for evaluate_baseline
evaluate_baseline looks for <output_dir>/norm_stats_fold{N}.json per fold
(matching the convention written by train_baseline_supervised.rs:812).
train_baseline_rl never produced this file — the fxcache has a single
<hex_key>.norm_stats.json written by precompute_features, which RL
training uses implicitly for pre-normalized features but never copies to
the per-fold paths evaluate wants. Result (L40S train-7r9zf):

  Error: NormStats not found at /workspace/output/norm_stats_fold0.json
  - cannot evaluate without training-set statistics (computing from test
    data would introduce lookahead bias). Run training first to generate
    this file.
  WARN: Evaluation failed, continuing

Evaluate fails gracefully but no report is produced.

Fix: new helper `fxcache::norm_stats_path_for_key(data_dir, override, cache_key)`
returns the canonical <hex_key>.norm_stats.json path that sits next to
the .fxcache file. train_baseline_rl resolves this once after the fxcache
load (falling back to None if the key is zero — i.e., DBN fallback path
with no precomputed stats) and copies it into the output dir as
norm_stats_fold{N}.json for every fold processed.

The RL-side fxcache norm stats are fold-independent (one z-score per
cache key, applied to all walk-forward windows), so the per-fold copies
are intentional duplicates of the same file — this matches evaluate's
per-fold lookup semantics without diverging from supervised convention.

Closes task #32.
2026-04-21 18:09:18 +02:00
jgrusewski
882497caa4 fix: OFI_DIAG reads new canonical indices [42..62), remove state_dim from evaluate_baseline
- OFI_DIAG now reads positions [42..62) using OFI_START constant instead of
  hardcoded [66..74). Verified: raw_mean=0.0891, delta_mean=-0.0370,
  book_agg=0.4500, log_dur=-0.2303 (was all zeros before).
- Removed 3 stale state_dim field initializers from evaluate_baseline.rs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:33:42 +02:00
jgrusewski
e41c4909f1 fix: validate OFI data content, not just has_ofi flag — v2 cache had all-zero OFI
The v2 fxcache on PVC passed has_ofi=true validation (mbp10_dir was present
when built) but contained all-zero OFI data. The old binary set the flag
based on directory existence, not actual computed values.

Now counts non-zero OFI rows before accepting a cache — forces rebuild
when MBP-10 data is available but OFI content is all zeros.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 12:08:11 +02:00
jgrusewski
4ce2fb7d4b fix: make OFI unconditional, fix *8→*20 dimension bug, remove 882 lines dead code
OFI (Order Flow Imbalance) features are mandatory — the model is worthless
without MBP-10 order book data. Every silent fallback that degraded to zeros
has been converted to a hard error.

Critical fixes:
- build_batch_states used *8 instead of *20 for OFI dimensions — every
  walk-forward backtest was reading corrupted OFI features from adjacent memory
- precompute_features early exit skipped cache rebuild when stale v2/v3
  cache existed with has_ofi=false — now validates has_ofi before skipping
- 14 silent OFI fallback paths converted to hard errors across data loading,
  training loop, experience collector, state construction, metrics, hyperopt

Dead code removed (-751 lines):
- DoubleBufferedLoader (superseded by init_from_fxcache)
- GpuBufferPool (superseded by init_from_fxcache)
- DqnGpuData::upload legacy method (no OFI support)
- CPU training fallback path (CUDA always required)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 11:50:26 +02:00
jgrusewski
429967dcd1 feat: precompute OFI deltas + book aggression + bar duration in fxcache
FXCACHE_VERSION 3→4. Three precomputed features added to OFI region:
- ofi[8..16): temporal deltas (ofi[bar] - ofi[bar-1]) for 8 features
- ofi[16]: book aggression (MBP-10 10-level center-of-mass asymmetry)
- ofi[17]: log bar duration (imbalance bar formation time, normalized)

Previously 10 of 18 OFI embed MLP inputs were zero. Now all 18 have
real data: raw_ofi(8) + delta_ofi(8) + book_aggression(1) + log_duration(1).

Added read_state_sample() diagnostic for GPU state verification.
Legacy v3 fxcache handled by zeroing new slots (v2→v4 graceful upgrade).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 08:11:03 +02:00
jgrusewski
063fd27166 feat: target_dim 4→6 + spec v5 with pearls (bar duration, book CoM, retrospective hold)
target_dim expansion: adds raw_open (OHLCV) and mid_price_open
(MBP-10 midpoint at bar formation) to fxcache targets. FXCACHE_VERSION
2→3 for auto-rebuild. Legacy v2 files handled with close-price fallback.

Spec v5 adds 3 pearls:
- Bar duration encoding in Mamba2 (continuous-time SSM awareness)
- Order book center of mass from all 10 MBP-10 levels (aggression signal)
- Retrospective hold quality bonus (teaches exit timing)

Plus: Hold action (4th direction), DSR Sharpe EMA fix, counterfactual
magnitude/order sign fix, MFT mid-price mark-to-market.

OFI embed MLP now 18→10 (was 16→8). Mamba2 width SH2+10 (was SH2+8).
Attention width D+10 (was D+8).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 23:47:04 +02:00
jgrusewski
64e6353a5d fix: IQN num_quantiles 64→32 in all binaries + production config
Default was hardcoded as 64 in train_baseline_rl.rs and evaluate_baseline.rs,
overriding the config default of 32. Added num_quantiles=32 to production
config so it's explicit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 13:06:58 +02:00
jgrusewski
108bb63fce feat: cost-driven hold timing — replace min_hold_bars with learned cost signals
Removed: enforce_hold(), min_hold_bars from config/kernels/backtest.
Added: holding_cost_rate (inventory penalty), churn_threshold_bars +
churn_penalty_scale (graduated flip penalty) to reward in
experience_env_step and backtest kernels.

The model learns optimal hold timing from cost signals:
- Per-trade tx cost prevents churning (existing)
- Inventory penalty makes large positions expensive to hold
- Churn penalty graduates cost for rapid flips
- Temporal attention learns when holding cost > expected profit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 01:04:11 +02:00
jgrusewski
30e94446cd fix: compute bars_per_day from fxcache timestamps — correct annualization
bars_per_day was hardcoded 390 (1-minute candles) but we use MBP-10
imbalance bars (~7500/day). All Sharpe/Sortino/return annualization
was off by sqrt(7500/390) ≈ 4.4×.

Now computed from actual fxcache timestamps:
  bars_per_day = total_bars / (trading_days from timestamp span)

Propagates to: val_Sharpe, train_Sharpe, financials, backtest evaluator.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 20:46:04 +02:00
jgrusewski
d78fe9d4a8 fix: fxcache version display + export FXCACHE_VERSION + cleanup dead code
precompute_features prints Format: f32 (v2), OFI_DIM=20 (was hardcoded v1).
FXCACHE_VERSION exported as pub const for external use.
graph_adam_m/v/step removed (dead buffers). q_mean_ema_ptr removed
from c51_grad_kernel (ISV replaced Q-drift penalty).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 02:12:33 +02:00
jgrusewski
8b68954ded feat(tick): precompute pipeline wires MicrostructureState — 20 OFI features
12 new tick-level features computed per bar from MBP-10 snapshots:
OFI trajectory, realized variance, Hawkes intensity, book pressure,
spread dynamics, aggression, queue depletion, order count flux,
intra-bar momentum, regime score, OFI acceleration, toxicity gradient.
Combined with existing 8 OFI into [f64; 20] per bar in fxcache.

Also fixes pre-existing bug: no-MBP-10 fallback was [0.0; 8] not [0.0; 20].

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 00:29:03 +02:00
jgrusewski
81771d7920 feat(tick): OFI_DIM 8→20 — atomic update across 15 files
fxcache: OFI_DIM=20 (pub const), RECORD_F64_COUNT=66, 272 bytes/bar.
constructor: state_dim 74→86 (aligned 88) with OFI.
experience collector: ofi_dim detection 8→20.
All [f64; 8] → [f64; 20]. Existing 8 features preserved at 0-7.
New 12 features zero-padded until precompute is extended.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 00:21:15 +02:00
jgrusewski
c74a687ea8 feat: position-gated episodes + 5000-bar limit — close 45x training/val gap
Episode done flag: timer-based -> position-gated (trade complete = done).
V(flat)=0 is correct terminal anchor. Soft reset keeps equity on
trade completion; hard reset only on data-end or capital breach.
H100: 100 bars -> 5000 bars, gpu_n_episodes -> 1024.
ExperienceProfile gains optional gpu_n_episodes field.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:56:44 +02:00
jgrusewski
61f1b0a1d2 fix: evaluate_baseline compile — make q_provider Optional, remove dead evaluate_dqn path
evaluate_dqn_graphed now takes Option<&mut dyn QValueProvider>.
Training eval passes Some(fused_ctx), standalone eval uses closure path.
Removed dead evaluate_dqn non-graphed branch from evaluate_baseline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 21:05:01 +02:00
jgrusewski
31c610b69a WIP: training quality fixes + VarStore elimination (INCOMPLETE — needs xavier_init_params_buf)
Phase 1 — Training quality (14 structural bugs):
- Reward: sqrt micro-reward scaling, remove sparse normalization, Kelly penalty,
  additive OFI, magnitude-scaled capital floor penalty
- Gradient: CQL 25→10%, IQN 60→75%, MSE magnitude 4×, 3-phase counterfactual,
  tau_anneal 100K→500K, epsilon floor 2%, PopArt fold reset, dd_threshold 0.5%
- Monitoring: per-branch Q-value instrumentation
- Hold: min_hold_bars 5→1

Phase 2 — VarStore elimination:
- Deleted copy_weights_from from all network types
- Deleted to_varstore from DuelingQNetwork + DistributionalDuelingQNetwork
- Deleted branching_to_varstore, 7 extract/sync VarStore functions
- Deleted update_target_networks, legacy CPU training paths
- Deleted iqn_target_network, target_network fields
- Deleted gpu_kernel_parity_test.rs (705 lines)
- Refactored GpuExperienceCollector to single flat buffer
- Added weight_sets_from_branching() zero-copy replacement
- EMA tau=1.0 init for target_params_buf

KNOWN ISSUE: params_buf is zero-initialized but from_flat_buffer() creates
weight set pointers into it BEFORE flatten_online_weights runs. The online
forward reads zeros → NaN. Needs xavier_init_params_buf() at construction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:03:05 +02:00
jgrusewski
9f18772527 fix: update all test/example files for VarStore removal + 7-level exposure
Add to_varstore() compatibility shims on DuelingQNetwork and
DistributionalDuelingQNetwork so test/example code can rebuild a
GpuVarStore snapshot when needed. Delete dead tests that referenced
removed DQNAgent, PrioritizedReplayBuffer, and ReplayBufferType.
Fix action index references (action_19/21 -> action_28/30) and
type annotation issues (sin ambiguity, remainder operator).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 15:21:21 +02:00
jgrusewski
1c3eb3e331 refactor: eliminate GpuVarStore — DuelingWeightSet is zero-copy pointer view into params_buf
DuelingWeightSet and BranchingWeightSet fields changed from owned
CudaSlice<f32> to raw u64 device pointers + usize element counts.
This enables zero-copy views directly into the flat params_buf for
the training hot path (no D2D copies, no shadow allocations).

Key changes:
- Weight sets store u64 + usize pairs instead of CudaSlice<f32>
- from_flat_buffer() creates zero-copy views into params_buf
- from_slices() creates pointer views from owned CudaSlice allocations
- DuelingWeightBacking/BranchingWeightBacking hold owned CudaSlice
  arrays for callers that need independent allocations (experience
  collector, ensemble heads, tests)
- flatten/unflatten become no-ops when weight sets point into params_buf
- extract functions return (Backing, WeightSet) tuples
- KernelWeightPack::build() uses u64 fields directly (no raw_device_ptr)
- All sync functions updated for pointer-based signatures
- Ensemble clone functions return backing + pointer view pairs
- gradient_budget tests updated (7/7 pass)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:02:55 +02:00
jgrusewski
448b61d095 refactor: collapse 9-level to 7-level ExposureLevel — eliminate degenerate Flat variants
The 4-branch DQN (direction x magnitude) had 3 degenerate variants
(Short25, Flat, Long25) that all mapped to 0.0 target exposure when
direction=Flat, causing 82% Flat collapse. Collapse these into a
single Flat variant, giving 7 levels (ShortSmall/Half/Full, Flat,
LongSmall/Half/Full) and 63 total factored actions (7x3x3).

- ExposureLevel enum: 9 variants -> 7 (add direction/magnitude/from_dir_mag)
- FactoredAction: 81 -> 63 total actions, from_index/to_index updated
- DQN epsilon-greedy: use from_dir_mag() instead of dir*3+mag indexing
- DQN config: num_actions default 9 -> 7
- PPO action space: 45 -> 63 actions, action masking updated
- Signal adapter CUDA kernel: 5-bin -> 7-bin exposure aggregation
- All tests updated for new variant names and index ranges

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:54:09 +02:00
jgrusewski
21abd2b0f9 fix: evaluate_baseline bf16→f32 remnants, delete old fxcache files
- Fix f32::from_f32/f32::ZERO (nonexistent) in evaluate_baseline.rs
- Eliminate redundant Vec copies (upload source data directly)
- Fix comments referencing __nv_bfloat16 in reductions.rs, training_guard_kernel.cu
- Delete 3 old bf16-format .fxcache files from test_data/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 18:57:36 +02:00
jgrusewski
4842a04011 refactor: eliminate ALL bf16 from entire workspace — pure f32/TF32 pipeline
Complete bf16 elimination across all crates (ml, ml-core, ml-dqn, ml-ppo,
ml-supervised). Zero half::bf16, __nv_bfloat16, or CudaSlice<half::bf16>
references remain (verified by grep).

CUDA: All 60+ .cu kernels and .cuh headers converted to native float.
  - Half-precision intrinsics (__hmul, __hadd, __hdiv) → float operators
  - atomicAddBF16 → native atomicAdd(float)
  - bf16 wrapper functions → f32 identity passthroughs

Rust: All CudaSlice<half::bf16> → CudaSlice<f32> across 90+ files.
  - htod_f32_to_bf16/dtoh_bf16_to_f32 → htod_f32/dtoh_f32 (direct, no conversion)
  - Deleted bf16 mirror infrastructure (DuelingWeightSetBf16, alloc_bf16_mirror, etc.)
  - Renamed params_bf16→params_flat, d_value_logits_bf16→d_value_logits, etc.
  - Fixed .to_f32() sed damage on Decimal::to_f32() and rng.f32()

FxCache: Single f32 disk format (was bf16/f64 dual-version).
  - Deleted --bf16 CLI flag from precompute_features
  - PVC cache files need regeneration via precompute_features

TF32 tensor cores activated via cublasLtMatmul CUBLAS_COMPUTE_32F — no
explicit TF32 types needed. Storage is pure f32 everywhere.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 18:52:21 +02:00
jgrusewski
00bbe76b05 refactor: fxcache single f32 format — delete bf16 version, remove --bf16 CLI flag
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 18:45:49 +02:00
jgrusewski
a2370d7f33 fix: evaluate_baseline example bf16→f32 closure signatures
The evaluate closures now receive &CudaSlice<f32> from the backtest
evaluator. Updated all 3 closures (DQN, PPO, supervised) to match.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:02:13 +02:00
jgrusewski
fea6371af7 fix(data): filter front-month PER FILE, not globally — each quarterly DBN has different contract
Global filter selected only ONE quarter's instrument_id, producing 3 months
of data instead of 2+ years. Now filters within each file so quarterly
rolls are handled correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:27:54 +02:00
jgrusewski
ced178812a feat(data): precompute + data_loading use trades volume bars instead of broken OHLCV
Replace extract_ohlcv_bars_from_dbn() with trades-based volume bar pipeline
(load_trades_sync -> filter_front_month -> build_volume_bars) in both the
precompute binary and the runtime fallback path, eliminating fake price jumps
from interleaved contract months in OHLCV DBN files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:36:05 +02:00
jgrusewski
bacaf2765a feat: leverage-based position cap (replaces hardcoded max_position)
max_position_absolute is now computed from max_leverage:
  max_position = floor(capital * max_leverage / (price * multiplier))

- Added max_leverage config field (default: 5.0)
- compute_max_position() derives position from leverage + median price
- Hyperopt risk_intensity scales max_leverage (not position directly)
- Updated all TOML configs: dqn-production, dqn-smoketest, dqn-localdev
- Hyperopt search space: max_leverage = [2.0, 10.0] (replaces [1.0, 4.0] contracts)
- GpuBacktestConfig wired with max_leverage for consistent eval

With $35K capital, ES at $5K, multiplier=50:
  5× leverage → floor(35000*5/250000) = 0.7 → 1 contract (safe)
  Old default 2.0 contracts → 14× leverage (dangerous)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:24:20 +02:00
jgrusewski
9c081ac7ce refactor: CLI binaries use shared fxcache::discover_and_load()
Replace 40-line inline fxcache discovery block in train_baseline_rl.rs
with a single call to ml::fxcache::discover_and_load(). precompute_features.rs
already uses correct symbol/data_source args — no changes needed there.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 10:05:26 +02:00