Commit Graph

4064 Commits

Author SHA1 Message Date
jgrusewski
4399a56d76 test(smoke): strengthen weak assertions across DQN smoke suite
Five tests in crates/ml/src/trainers/dqn/smoke_tests had pass gates that
validated existence rather than the invariant their doc-comment claimed
to test. A trainer returning all-zero diagnostics (a plausible wiring
regression) would have passed them.

- reward_component_audit: was `is_finite() && >= 0.0` on 5 slots —
  passes trivially on all-zero stubs. Added `cf_flip > 0.1` and
  `trail_r > 0.01` floors (known-wired slots in smoke config;
  popart/micro/loss_aversion remain finite-only as they are
  legitimately near-zero in smoke). Run-observed values: cf=0.614,
  trail=0.295, la=0.006 — well above floors.

- exploration_coverage: `.unwrap_or(0.0)` silently substituted 0 for a
  missing epoch, conflating "emission regressed" with "exploration
  collapsed". Now panics with a distinct message on missing entries,
  also asserts len >= 20, normalized range [0,1], and spread > 1e-6 to
  catch constant-output emitters. Run-observed spread: 0.275.

- training_stability::50_epoch_convergence: entropy assertion was
  guarded behind `if entropy.is_finite()`, so NaN entropy (the more
  severe failure) silently passed. Fail hard on NaN first.

- training_stability::trading_model_behavior: same `is_finite` guard
  pattern on action_entropy — now fails hard when the diagnostic is
  missing or NaN rather than skipping.

- training_stability::gpu_collector_auto_initializes: only asserted
  training returned `Ok(_)`. A collector producing silent zeros would
  pass. Now also verifies epochs_trained, loss finiteness, and
  gradient flow.

- walk_forward::no_overfitting_50_epochs: had two tautological "finite
  check" assertions (`x < x + 1` and the signum-adjusted ratio) that
  always passed regardless of divergence. Replaced with real
  `.is_finite()` checks plus a `div_ratio < 10.0` stability gate.

Tests run under CUBLAS_WORKSPACE_CONFIG=:4096:8 +
FOXHUNT_TEST_DATA=test_data/futures-baseline, release-test profile.
reward_component_audit and exploration_coverage both PASS on the
local RTX 3050. No threshold relaxation or quickfixes applied; any
future wiring regression will now be caught by a meaningful
assertion instead of a near-tautology.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 01:50:22 +02:00
jgrusewski
ed4b30b493 fix(smoke): controller_activity — V7 intervention-based fire detection
Redefine the "fire" semantic for adaptive controllers per the V7 audit
(policy-quality-design spec §5.3): a controller fires iff it made an
ADAPTIVE INTERVENTION this epoch, not merely because its observable
output value changed.

Previously fire detection was absolute-delta on the output:
- grad_clip fired in 98.3% of epochs because the adaptive clip threshold
  is an EMA recomputed every training step. The EMA drifts by > 1e-3
  every epoch regardless of whether the clip actually clamped a gradient.
- cost_anneal fired in 98.3% of epochs because it is a deterministic
  sigmoid of current_epoch (1/(1+exp(-(epoch-10)/3))) with no adaptive
  or reactive component. Every epoch moves it by > 1e-4 by design.

Neither was "load-bearing" in the V7 sense — one was a per-step EMA
tracker, the other a pure curriculum schedule. The prior test output
"controller 'grad_clip' fires in 98.3%" was a false positive from
measuring the wrong signal.

New semantics:
- anti_lr / tau / gamma / cql_alpha: unchanged — absolute delta vs
  prior epoch on the effective output value (real adaptive controllers).
- grad_clip: intervention-based latch `grad_clip_kicked_this_epoch`,
  set in run_training_steps_slices iff raw_grad_norm > active clip at
  any training step this epoch. Reset in reset_epoch_state.
- cost_anneal: pure deterministic schedule → never load-bearing →
  always fires=false. The value is still tracked in prev_controller_values
  and the HEALTH_DIAG line still emits it for observability, but it
  cannot trip the 50% load-bearing gate.

After fix, controller_activity smoke reports:
  anti_lr=0.000 tau=0.033 gamma=0.017 clip=0.233 cql=0.033 cost=0.000
All 6 rates ≤ 0.5. Test passes.

Touched:
- crates/ml/src/trainers/dqn/trainer/mod.rs (add grad_clip_kicked_this_epoch)
- crates/ml/src/trainers/dqn/trainer/constructor.rs (init new field)
- crates/ml/src/trainers/dqn/trainer/training_loop.rs (latch kick per step,
  redefine fire_clip + fire_cost in HEALTH_DIAG block)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 01:16:45 +02:00
jgrusewski
a5f23b28f0 fix(diag): move GPU summary download before HEALTH_DIAG emit
Third root cause of the apparent magnitude collapse in smoke-test data:
HEALTH_DIAG reads monitor.action_counts to compute dist_q/h/f and
ent_mag/ent_dir, but the block that populated action_counts from the
GPU summary ran AFTER HEALTH_DIAG. Every epoch saw all-zero counts so
dist_q=dist_h=dist_f=ent_mag=ent_dir=0 in every log line, and the
last_magnitude_dist cache the smoke test reads was always zeros.

Moved the GPU-summary download + monitor population block up above the
HEALTH_DIAG preparation block. Signal is now real per-epoch:

  dist_q=0.60 dist_h=0.15 dist_f=0.25  (was 0/0/0)
  ent_mag=0.83  ent_dir=0.89           (was 0/0)

magnitude_distribution smoke test now PASSES on local RTX 3050 Ti at
20 epochs. Final: Quarter=0.598 Half=0.154 Full=0.249 — all above 5%
smoke threshold.

Combined with 2fb30f098 (9→12 bin cascade + magnitude-preference
denominator restricted to tradable directions), this resolves the
Track-1 "magnitude collapse" pathology. The collapse was never a
policy-learning failure — it was a monitoring visibility + ordering
bug that made every reading look like mag=0 100%.
2026-04-22 01:03:17 +02:00
jgrusewski
2fb30f098e fix(monitoring): cascade 9→12 action bins for 4-direction action space
The runtime DQN action space is 4×3×3×3 = 108 (dir×mag×order×urgency) per
the kernel's b0_size=4 (Short/Hold/Long/Flat) layout, but the monitoring
stack was still coded for the legacy 3×3=9-bin exposure layout. Flat
actions (dir=3) had exp_idx = 9 >= num_actions=9 and were silently
dropped by the monitoring_reduce kernel's bounds check.

Cascade:
- monitoring_kernel.cu: num_exposure_bins=12 (EXP_MAX constant), summary
  layout 27 floats with bins [5..17), order [17..20), urgency [20..23),
  N [23], trades [24], pad [25..27).
- gpu_monitoring.rs: MonitoringSummary.action_counts [usize; 12];
  summary_buf 27 f32; reduce() now takes b0_size and validates
  num_exposure_bins ≤ EXP_MAX.
- trainers/dqn/monitoring.rs: action_counts/q_value_sums/q_value_counts
  all [_; 12], factored_action_counts [_; 108], EXPOSURE_NAMES with 4
  dirs × 3 mags ("S_Small".."F_Full"); track_action now maps the 8-level
  ExposureLevel enum onto the kernel's dir*3+mag layout by going through
  direction()/magnitude() accessors (was indexing by raw `exposure as
  usize` which is an entirely different scheme).
- trainer/training_loop.rs: total_action_counts [_; 12],
  total_factored_action_counts [_; 108]; GPU monitoring reduce call
  now passes b0 from agent.branch_sizes(); direction-entropy gains a
  4th dir bin; per-action Q diagnostics names array extended to 12.
- trainer/metrics.rs: create_final_metrics arrays widened; q_diagnostics
  per_action_avgs [_; 12]; combo_idx = d*3 + m (matching kernel layout);
  action_space_size 108/12 and buy/sell/hold uses the new exp_idx
  boundaries.
- financials.rs: action_counts [_; 12]; buy/sell/hold classification
  updated — BUY = Long [6..9], SELL = Short [0..3], HOLD = Hold + Flat
  ([3..6] ∪ [9..12]). Tests updated for 12-bin layout.

Also fixes several collateral bugs exposed by the audit:

1. magnitude_action_dist_final conflated Hold (dir=1, mag-forced-to-0)
   with "Quarter magnitude". New formula restricts the denominator to
   tradable directions (Short + Long) and computes Q/H/F fractions only
   over those, producing the real magnitude preference signal.

2. training_loop.rs:3012 flat_count indexed [3,4,5] (which is Hold in
   the new layout, but was already the wrong bucket — "Flat" was never
   at that index even under the legacy 9-bin reading, where [3,4,5]
   was the Flat bin but mag was forced to 1 not 0). New code correctly
   separates flat_count [9..12] from hold_count [3..6] and excludes
   both from the directional denominator and diversity cell threshold.

3. monitoring.rs had q_value_sums: [f64; 7] but q_value_counts:
   [usize; 9] — different sizes for what should be the same exposure
   axis. Both now [_; 12].

4. log_action_distribution iterated over 7 indices when printing per-
   action Q-values but the array is logically 12-wide — now uses
   `0..12` with the 12-entry EXPOSURE_NAMES lookup.

Tests: all 7 financials tests pass. Monitoring cubin loads + default
summary sanity checks pass. Smoke test magnitude_distribution now
emits correct 12-bin breakdown — GPU summary shows (example epoch 20):

  actions=[786, 157, 280, 377, 2, 7, 806, 156, 317, 303, 5, 4]

which decodes as Short (S) = 786/157/280 (~38%), Hold (H) = 377/2/7
(~12%, mag-forced), Long (L) = 806/156/317 (~40%), Flat (F) =
303/5/4 (~10%, mag-forced). Previously the Flat bucket (303+5+4 =
~10% of all actions) was silently dropped — policy was picking Flat
~10% of the time and nobody could see it.

No atomic ops added; all reductions remain via warp-scratch + lane-0
sequential merge. No feature flags, no stubs, no quickfixes.
2026-04-22 00:55:25 +02:00
jgrusewski
2ac956298b fix(data-loading): truncate OFI in lockstep with bars on max_bars path
Two truncation sites in data_loading.rs truncated feature/target vectors
to max_bars but left self.ofi_features at the original (cache-full)
length. Downstream DqnGpuData::upload_slices asserts OFI len == num_bars
and surfaced this as "OFI/market data length mismatch: 175874 OFI rows
vs 5000 bars — fxcache is corrupt".

The cache was not corrupt — the upstream truncation was the bug. Fixed
by moving OFI storage below the truncation block in the fxcache path and
by adding a parallel truncate in the DBN fallback path. Added
debug_assert_eq on equal lengths post-truncation so future regressions
hard-fail in dev.

Also corrected the cuda_pipeline/mod.rs error message to point at the
actual culprit (upstream truncation desync) instead of blaming the
on-disk cache.
2026-04-22 00:13:08 +02:00
jgrusewski
83d524f866 diag(policy-quality): remediate stub return values per no-stubs rule
WIRE:
  * q_mag_full/half/quarter — host-side mean of magnitude-branch q_out_buf
    cols [b0..b0+b1] (Task 0.3 stub remediated). Cold-path dtoh at epoch
    boundary via update_q_mag_means_cached(), cached in q_mag_means_cached,
    read by q_magnitude_bucket_means().
  * var_scale_mean — per-sample CudaSlice + host reduce over var_scale
    written by experience_env_step at line 1422 (Task 0.3 stub remediated).
    Host reducer averages only over slots where kernel wrote > 0 so
    Var[Q]-disabled samples don't dilute the signal.

DELETE:
  * segment_patience slot from reward_contrib group — term not wired in
    kernel, slot reserved space for non-existent feature (Task 0.8 stub
    remediated). reward_contrib [...] is now 5 floats not 6. Cascaded
    through reward_contrib_fractions return type, trainer's
    last_reward_contrib field, reward_component_audit_summary accessor,
    and the reward_component_audit smoke test.

PopArt slot kept at 0.0 — that IS the semantic value during warmup or
disabled state, not a stub. Comment updated to make this explicit.

Per ~/.claude/.../memory/feedback_no_stubs.md: stubs strictly forbidden
even with code comments / commit-message documentation / plan sanction.
2026-04-22 00:00:39 +02:00
jgrusewski
aa16ca31f2 diag(policy-quality): finish Tasks 0.3 + 0.6 partial wirings
Task 0.3 (Track 1 magnitude):
  * q_mag_full/half/quarter: plumbed via GpuDqnTrainer::q_magnitude_bucket_means()
    → FusedTrainingCtx::q_magnitude_bucket_means() → HEALTH_DIAG. Phase-0
    returns [0.0; 3]; per-mag Q reduction kernel deferred per plan §0.3 step 2
    (goal here is the accessor chain, swap-in without touching emit formatting).
  * var_scale_mean: STUB 0.0 — kernel computes `1/(1+sqrt(Var[Q]))` per-sample
    inside experience_env_step but does not persist to a device buffer; it is
    consumed in-place to shrink effective_max_pos. Exposing requires a
    dedicated per-sample output buffer + launch arg + kernel write; deferred
    to Phase 1+. Documented in code.
  * kelly_f_mean / avg_win_ratio: REAL — computed host-side from
    self.trade_stats_history.last() using the SAME formula the kernel uses
    (b = avg_win/avg_loss, kelly = (b·p − (1−p))/b clamped [0,1] × 0.5
    for half-Kelly; avg_win_ratio = (sum_wins/win_count) /
    max(sum_losses/loss_count, 0.001) per plan formula line 239).
    One-epoch lag (trade_stats_history is appended later in the same
    epoch body); zero until first epoch's stats land. Documented in code.

Task 0.6 (Track 1 noisy):
  * vsn_mag / vsn_dir: REAL per-branch — mean |w| across each branch's VSN
    projection weight pair (tensors 26..34 grouped by branch:
    26/27=dir, 28/29=mag, 30/31=order, 32/33=urg). The live VSN gate mask is
    computed per-sample inside variable_select_bottleneck and consumed
    immediately, not materialized into a device buffer we can cheaply read;
    projection-weight mean is the closest stable H7 surrogate. Documented
    in accessor rustdoc. Higher-fidelity measurement would require a
    per-sample mask output buffer following the Task 0.5 pattern — deferred.
  * drift_mag / drift_dir: REAL per-branch — RMS ‖target_w − online_w‖ across
    each branch's 4 weight tensors (indices 8..12 dir, 12..16 mag, 16..20
    order, 20..24 urg). Host-computed cold path via two memcpy_dtoh + normal
    Vec<f32> reduce loops; stream-sync'd before read. NO atomics (project rule).
    ~2.7 MB × 2 DtoH per epoch, negligible diagnostic cost.

NoisyNets sigma_mag/sigma_dir already wired in 999bb2fa0 — preserved untouched.

Accessor chain (mirrors Task 0.4 grad_ratio_mag_dir pattern):
  * GpuDqnTrainer::per_branch_target_drift() -> Result<[f32; 4], MLError>
  * GpuDqnTrainer::per_branch_vsn_mean()     -> Result<[f32; 4], MLError>
  * GpuDqnTrainer::q_magnitude_bucket_means()-> [f32; 3]  (plumbing stub)
  * FusedTrainingCtx wrappers → delegate, fallback to zeros on MLError.
  * training_loop.rs HEALTH_DIAG block: populate slots via
    self.fused_ctx.as_ref().map(|f| f.…).unwrap_or(…) — same pattern
    used for grad_ratio_mag_dir.

HEALTH_DIAG `mag` and `noisy` groups now carry real signal (or plan-sanctioned
plumbing stubs for the q_mag_* / var_scale slots documented in commit + code).
2026-04-21 23:39:08 +02:00
jgrusewski
e1ac2c7578 docs(policy-quality): Task 0.17 baseline metrics scaffold (PENDING GPU run)
Phase 0 instrumentation is complete (Tasks 0.4 / 0.5 / 0.8 / 0.10 / 0.16
shipped this session; 0.6 partial; 0.3 partial). This scaffold encodes
the full metric set with explicit <PENDING …> markers per row so the
GPU-run capture can fill in values without forgetting any HEALTH_DIAG
field.

The policy-quality-baseline tag is intentionally NOT applied yet —
tagging requires real captured values, not the scaffold. Runner workflow:

  for t in magnitude_distribution reward_component_audit \\
           controller_activity exploration_coverage multi_fold_convergence; do
    SQLX_OFFLINE=true CUBLAS_WORKSPACE_CONFIG=:4096:8 \\
      FOXHUNT_TEST_DATA=test_data/futures-baseline \\
      cargo test -p ml --release --lib -- \$t --ignored --nocapture 2>&1 | tee out_\$t.log
  done

Then edit this doc, replace markers with values, amend, and tag.
2026-04-21 23:25:02 +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
bf8415c5d6 diag(policy-quality): Task 0.8 reward-term contribution diag (Track 2)
Per-sample contribution arrays for additive terms (micro, loss_aversion,
segment_patience) + sample-selector flags (cf_flip), summed host-side
and divided by total reward magnitude. Trail rate reuses Task 0.5
buffers (peek without reset).

PopArt slot: REAL — computed from FusedTrainingCtx::read_popart_variance()
as |Δvar| / |var_pre| tracked across epochs. Returns 0.0 during warmup
(first 10 batches of PopArt stats) or when PopArt is disabled.

segment_patience slot: STUB (0.0) — the kernel has no patience term
wired. The sparse-reward formula comment (experience_kernels.cu:1049)
mentions trade_return * patience_mult but no multiplier lives in the
live code path. Slot preserved for HEALTH_DIAG schema stability; will
carry real signal if/when segment patience ships.

No atomicAdd — each thread writes its own [i*L+t] slot (or [cf_off]
for cf_flip). Buffers reset via memset_zeros after readback. Order
matters: reward_contrib_fractions peeks trail/traded buffers without
reset so trail_fire_and_hold_per_mag can read+reset them afterward.

HEALTH_DIAG reward_contrib group now carries real signal for 5 of 6
slots. Smoke test reward_component_audit_summary returns the cached
latest reading (was all-zero stub).
2026-04-21 22:57:51 +02:00
jgrusewski
48d962ee24 diag(policy-quality): Task 0.5 trailing-stop per-mag fire/hold (H6 signal)
Per-sample i32/f32 output arrays sized [alloc_episodes*alloc_timesteps]
populated by experience_env_step at every (i,t). Magnitude bin computed
from pre_trade_position (the position that just got trailed-out), NOT
post-enforcement actual_mag_core (which is always 0 on trail fire).

Host-side deterministic reduction in trail_fire_and_hold_per_mag().
NO atomicAdd — each thread owns its own [i*L+t] slot. Buffers reset
via memset_zeros after each epoch readback.

Updates HEALTH_DIAG trail group with real trail_fire_rate[3] and
hold_at_exit_mean[3] (Quarter/Half/Full bins = 0/1/2).
2026-04-21 22:32:10 +02:00
jgrusewski
93d8c5ae4a test(policy-quality): Task 0.12 — reward_component_audit smoke
Adds smoke test asserting the reward-contribution diagnostic produces
finite, non-negative values for all 6 reward terms (popart, cf_flip,
trail, micro, loss_aversion, segment_patience).

The smoke test validates the DIAGNOSTIC INFRASTRUCTURE — that the
per-term contribution accessor doesn't return NaN/Inf/negative values
that would break log parsing in Phase 1 audit. The actual triage
(KEEP/DELETE per term per spec §5.2) happens against a multi-fold
L40S training log in Phase 1, not in this smoke.

DQNTrainer.reward_component_audit_summary() returns [f32; 6] with
explicit measurement-class semantics per spec §4.1:
  - additive: fraction of |total reward|
  - transform (popart): |delta| / |pre|
  - sample-selector (cf_flip, trail): firing rate

Currently returns zeros — Task 0.8 wires kernel-side instrumentation
to populate real values. Smoke remains green throughout (zeros pass
finite + non-negative gates), and ensures any future kernel-side
breakage that produces NaN/Inf is caught immediately.
2026-04-21 22:01:38 +02:00
jgrusewski
999bb2fa0c diag(policy-quality): NoisyNets σ per branch — partial Task 0.6, completes 0.10
Wires Track 1 noisy_mag/noisy_dir + Track 4 sigma_mean fields with the
mean |sigma| across each action branch's NoisyLinear fc + out layers.
Branch 0 = direction, Branch 1 = magnitude. H7 detection signal — if
magnitude branch has 2x larger σ than direction, NoisyNets noise is
dominating the magnitude head's effective signal.

Cross-crate API chain (no shortcuts):
  * ml-dqn::noisy_layers::NoisyLinear::sigma_mean() -> mean |weight_σ| + |bias_σ|
  * ml-dqn::branching::BranchingDuelingQNetwork::branch_noisy_sigma_mean(idx)
    averages fc + out σ for the named branch
  * ml-dqn::dqn::DQN::branch_noisy_sigma_mean(idx) — None-tolerant proxy
  * ml::trainers::dqn::config::DQNAgentType::branch_noisy_sigma_mean(idx)
    delegates through primary_head
  * HEALTH_DIAG reads via self.agent.read().await at epoch boundary

Pinned-readback pattern not used here — NoisyLinear weights live in
candle-managed CudaSlices, not flat trainer params buffer. Per-call
dtoh of ~256 + 768 floats × 2 layers × 4 branches = ~8KB total per
epoch. Negligible.

Track 0.10 (exploration entropy + sigma_mean) is now COMPLETE — the
sigma_mean field that was previously stubbed is now real.
Task 0.6 remains partial: VSN mask (vsn_mag, vsn_dir) and target drift
(drift_mag, drift_dir) still stubbed — they require separate accessors
on different layer types (VSN module + target_params_buf reductions).
2026-04-21 21:59:53 +02:00
jgrusewski
bb42c99636 diag(policy-quality): Task 0.4 — per-branch grad norm via pinned readback
Wires grad_ratio_mag_dir HEALTH_DIAG field with the magnitude head's
gradient L2 norm divided by the direction head's. H4 detection signal
(magnitude-head gradient starvation).

Implementation:
  * GpuDqnTrainer.grad_readback_pinned_ptr — pinned host buffer sized
    TOTAL_PARAMS f32, allocated once at construction. Plain pinned
    (not device-mapped) — written via memcpy_dtoh.
  * per_branch_grad_norms() -> [f32; 4]: sync stream, dtoh full grad
    buffer to pinned host, compute L2 norm per branch slice using
    compute_param_sizes + padded_byte_offset (branch tensors at indices
    8-11 / 12-15 / 16-19 / 20-23 for direction/magnitude/order/urgency).
  * grad_ratio_mag_dir() -> f32: convenience accessor for HEALTH_DIAG.
  * fused_training::FusedTrainingCtx::grad_ratio_mag_dir(&mut) — exposes
    via the wrapper used by training_loop.

Performance: pinned dtoh of ~2.7 MB (667K params × 4 bytes) takes ~0.5ms
on PCIe 4. Stream-sync cost is per-epoch (not per-step), acceptable for
diagnostic readback. Pageable-memory dtoh would be ~3-5x slower.

Drop free of the pinned buffer added alongside other pinned slots.

Per plan Task 0.4. Complete — no stubs.
2026-04-21 21:51:39 +02:00
jgrusewski
0310b1d1eb diag(policy-quality): Task 0.7 — eval-mode action distribution (H10 signal)
Wires the eval_dist HEALTH_DIAG group with per-magnitude action distribution
read from the validation backtest. H10 detection signal — training-mode
entropy may look uniform while eval-mode argmax collapses to Quarter.

Implementation:
  * GpuBacktestEvaluator::read_eval_action_distribution_per_magnitude()
    reads actions_history_buf to host, decodes magnitude bin per sample
    (action layout: dir*27 + mag*9 + ord*3 + urg → mag = (a/9) % 3),
    returns [Quarter, Half, Full] normalized over non-skipped entries.
  * DQNTrainer.last_eval_magnitude_dist field, populated in metrics.rs
    after each validation backtest. Non-fatal on readback error.
  * HEALTH_DIAG eval_dist group [eq, eh, ef] now shows real values.

Per plan Task 0.7. Complete — no stubs.
2026-04-21 21:43:01 +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
93471d85a2 diag+test(policy-quality): Task 0.9 + 0.13 — controller fire-rate tracking
Adds delta-based adaptive-controller fire detection. A controller 'fired'
in epoch N iff its observable output changed from epoch N-1 beyond a small
numerical threshold. Applied to all 6 controllers per spec §5.3:
  - anti_lr          (threshold 1e-10 on LR)
  - tau              (1e-6 on target-net tau)
  - gamma            (1e-4 on discount factor)
  - grad_clip        (1e-3 on adaptive clip threshold)
  - cql_alpha        (1e-5 on CQL pessimism weight)
  - cost_anneal      (1e-4 on tx-cost anneal factor)

Adds:
  * DQNTrainer.prev_controller_values + .controller_fire_counts
  * ControllerPrevValues (NaN sentinel on first epoch = no fire)
  * ControllerFireCounts (running u32 per controller)
  * pub fn controller_fire_rates_final() -> [f32; 6]
  * HEALTH_DIAG 'controller' group shows per-epoch fire bools + max
    running fire rate across all controllers (fire_frac)

Adds smoke test controller_activity.rs asserting no controller fires in
> 50% of epochs. Expected to FAIL on current main — anti-LR fires every
epoch per today's session observation. Documents the load-bearing issue.

Completes Tasks 0.9 + 0.13 per plan. No partial state — all 6 controllers
are delta-tracked, all fire rates exposed, the smoke test asserts against
all of them.
2026-04-21 21:34:36 +02:00
jgrusewski
9004c9b0a2 test(policy-quality): Task 0.14 — exploration_coverage smoke
Asserts magnitude-branch action entropy stays meaningful during training:
  - entropy @ epoch 5 (0-idx 4) ≥ 0.5
  - entropy @ epoch 20 (0-idx 19) ≥ 0.3

Uses the ent_mag values populated in HEALTH_DIAG by commit ec035ca3a.
Adds DQNTrainer.explore_entropy_mag_history Vec + public accessor.

Expected to FAIL on current main (documents current collapse speed).
Per plan Task 0.14 at docs/superpowers/plans/2026-04-21-policy-quality.md.
2026-04-21 21:28:22 +02:00
jgrusewski
ec035ca3a6 diag(policy-quality): wire per-branch action entropy (ent_mag, ent_dir)
Adds module-level shannon_entropy_normalized helper and computes per-branch
action entropy from the existing monitor.action_counts data:
  * ent_mag = entropy over Quarter/Half/Full distribution
  * ent_dir = entropy over direction-bin distribution (indices 0-2 / 3-5 / 6-8)

Normalized to [0, 1] so 0 = full collapse, 1 = uniform.

Does not complete Task 0.10: sigma_mean (NoisyNets σ overall) remains 0.0
until Task 0.6 wires the per-branch NoisyNets σ readback (separate commit).
2026-04-21 21:25:43 +02:00
jgrusewski
691a7669a3 test(policy-quality): add magnitude_distribution smoke + accessor
Phase 0 Task 0.11 (and completes Task 0.3 action_dist wiring):

  * DQNTrainer.last_magnitude_dist: [f32; 3] — [Quarter, Half, Full]
    cached at end of each epoch from monitor.action_counts
  * pub fn magnitude_action_dist_final() → [f32; 3] accessor
  * new smoke test magnitude_distribution.rs — asserts F_Half/F_Full ≥ 5%
    after 20-epoch train on fxcache smoke data

Expected to FAIL on current main (observed: F_Half ≈ 0.2%, F_Full ≈ 0.5%)
— documents the bug per spec §1.1. Will pass after Phase 2 fixes land.

Tracked as Task 0.11 in docs/superpowers/plans/2026-04-21-policy-quality.md.
2026-04-21 21:23:04 +02:00
jgrusewski
6ce0382cf7 diag(policy-quality): Task 0.3a — wire action_dist_* from monitor
Replaces 3 zero stubs in the HEALTH_DIAG mag group with real values
computed from monitor.action_counts. Quarter = indices 0,3,6 /
Half = 1,4,7 / Full = 2,5,8, all divided by total epoch actions.

Other 7 mag-group fields (q_full/half/quarter, var_scale, kelly_f,
avg_win_ratio, grad_ratio_mag_dir) still stubbed — require GPU readback
infrastructure (per-magnitude Q reduction, var_scale running mean,
per-branch grad norm) landed in follow-up commits.

Partial Task 0.3 per docs/superpowers/plans/2026-04-21-policy-quality.md.
2026-04-21 21:18:57 +02:00
jgrusewski
592aaaf505 diag(policy-quality): scaffold HEALTH_DIAG fields for 4-track V7 audit
All new fields wired with zero/false values. Later tasks replace zeros with
real GPU-sourced measurements. Per plan Task 0.2 at
docs/superpowers/plans/2026-04-21-policy-quality.md.

37 new field placeholders across 4 V7 tracks:
  mag [10 f32]           — Track 1 magnitude diagnosis (H1-H5)
  trail [6 f32]          — Track 1 trailing stop per bin (H6)
  noisy [6 f32]          — Track 1 VSN/NoisyNets/target-drift (H7-H8)
  eval_dist [3 f32]      — Track 1 eval-mode action dist (H10)
  reward_contrib [6 f32] — Track 2 reward component audit
  controller [6 bool + 1 f32] — Track 3 firing rates
  explore [3 f32]        — Track 4 entropy + sigma

No behavioral change — all values zero/false until later tasks wire
real readbacks. cargo check clean.
2026-04-21 21:13:31 +02:00
jgrusewski
944fecd913 docs(policy-quality): main-branch workflow — cut feat/wip branches
User preference: everything lands on main, ~500 LOC is small enough
that feature-branch isolation isn't needed.

Spec changes:
  * §2.3 outcome paths: drop "unmerged branch" vocabulary
  * §3 architecture: main-branch timeline, no feat/, no wip/
  * §4 Phase 0: commits on main, tag policy-quality-baseline at end
  * §5 Phase 1: experiments are worktree edits reverted after, only
    triage docs commit (to main)
  * §6 Phase 2: commits land on main, author chooses granularity,
    each leaves smokes green
  * §7.3 outcome handling: iterate on main, baseline tag for rollback
  * §7.4 closing: tag policy-quality-v1, update status

Plan changes:
  * Task 0.1: verify-starting-state (not branch creation)
  * Task 1.x: temp-edit-then-revert (not wip branches)
  * Task 1.5: triage docs commit directly to main
  * Phase 4: tag + close (not merge + cleanup)
  * Rollback: one git reset --hard command
2026-04-21 21:06:33 +02:00
jgrusewski
1456094a5a plan(policy-quality): implementation plan for 4-track V7 audit
Produced by superpowers:writing-plans from the approved spec at
docs/superpowers/specs/2026-04-21-policy-quality-design.md.

Plan structure:
  * Phase 0 (tasks 0.1-0.17): measurement substrate — TDD-detailed,
    exact code + commands. Produces 6 new smoke tests + ~25 HEALTH_DIAG
    fields across 4 tracks.
  * Phase 1 (tasks 1.0-1.5): parallel investigation tracks — process
    tasks (run instrumentation, record evidence, classify hypotheses).
    No code to pre-write; output is 4 triage documents + a Phase-2
    planning doc.
  * Phase 2 (template only): convergence fix. Concrete tasks produced
    by a Phase-2-specific plan AFTER Phase 1 triage — can't pre-write
    the fixes without knowing which hypotheses confirm.
  * Phase 3 (tasks 3.1-3.4): L40S validation gates, surrogate-noise,
    branch decision (merge or iterate).
  * Phase 4 (tasks 4.1-4.4): merge via --no-ff, tag, cleanup.
  * Rollback procedure: git reset --hard policy-quality-baseline.
2026-04-21 20:56:45 +02:00
jgrusewski
ad677466c8 design(policy-quality): revision 4 — functional gaps
Addressed functional concerns from third review:

1. Surrogate-noise gate was statistically weak — "random Sharpe ≤ 0.5×
   trained Sharpe on same val data" could be satisfied by sample
   noise. Replaced with percentile-based test:
   * Pool all 6 val folds (~600 trades) for statistical power.
   * Run N=30 surrogate random-action rollouts (matched marginal
     action distribution — so difference is state-action mapping,
     not action frequency).
   * Assert trained pooled Sharpe > 95th-percentile of surrogate
     distribution. Explicit false-positive control.

2. Escalation path ("scope exceeds sub-project A v2") was hand-wavy.
   Now concrete: failure after 3 iterations → paused, not closed →
   triage spec opened with evidence summary + diagnosis (policy/
   reward/state/architecture/data bottleneck) + explicit decision
   (continue on feat/policy-quality or fork feat/policy-quality-v2).
   Triage itself is a full brainstorming cycle.

3. HEALTH_DIAG §4.2 Track 1 fields only covered H1–H5 — H6–H10
   needed their own detection signals. Added:
   - trail_fire_rate_{quarter,half,full}        (H6)
   - hold_time_at_exit_{quarter,half,full}      (H6)
   - vsn_mask_{magnitude,direction}             (H7)
   - noisy_sigma_{mag_head,dir_head}            (H7)
   - target_drift_{mag_head,dir_head}           (H8)
   - action_dist_eval_{quarter,half,full}       (H10)
2026-04-21 20:47:06 +02:00
jgrusewski
b5b70fdd8e design(policy-quality): revision 3 — internal consistency pass
Fixed inconsistencies between sections after the rev-2 feature-branch
refactor (sections had gotten out of sync):

1. §2 (success criteria) rewritten — was stale from rev 1. Now has
   clean mandatory/soft split matching §7.2, corrects the trade-count
   gate (per-fold not averaged), references the surrogate-noise gate.
   New §2.3 enumerates the outcome paths.

2. §4 and §6 no longer say "commit on main" — both land on
   feat/policy-quality per the feature-branch architecture. Phase 4
   merges to main at §7.4.

3. §7.3 outcome handling rewritten — was written for single-branch
   model ("Phase 2 commit is NOT reverted"). New wording matches
   feature-branch semantics: failing mandatory = don't merge; failing
   soft = merge + follow-up.

4. §6 smoke-tests rule now lists all six Phase 0 smokes, not just
   Track 1/2.

5. §7.4 Phase-4 merge-strategy added — --no-ff merge commit, tag
   policy-quality-v1, cleanup sequence documented.

6. §5.5 conflict-resolution added — tracks can reach contradictory
   conclusions (e.g. T1 says fix, T2 says delete). Resolution rules:
   mandatory-gate dominance, simplification wins, escalation.

7. §4.3 baseline metrics now committed to the feature branch (not
   "investigation-only"), matching the crash-safety discipline.

8. Budget / risk register aligned — 5.5–6.5 hrs plan + 1 buffer,
   hard-capped at 3 Phase-3 validation runs.
2026-04-21 20:44:09 +02:00
jgrusewski
5552517f61 design(policy-quality): caveat Criterion A for H9 (delete-magnitude-branch) case
If H9 confirms and Phase 2 removes the magnitude branch, F_Half/F_Full
gates become vacuous. Substitute: direction bin distribution healthy
(F_Short + F_Long each >= 20% of non-Flat actions). Preserves the
'model actually trades' intent of Criterion A.
2026-04-21 20:40:10 +02:00
jgrusewski
26be2b8abe design(policy-quality): revision 2 — full 4-track V7 + outside-the-box
Addresses critique from second self-review:

1. SCOPE GAP fixed: spec now covers all 4 V7 categories the user asked
   for. Added Track 3 (controllers) and Track 4 (exploration) with their
   own smoke tests (controller_activity, exploration_coverage) and
   HEALTH_DIAG fields. Previous version only had action-space + reward.

2. H1-H5 expanded to H1-H10 with 5 new codebase-specific hypotheses:
   - H6: Regime-adaptive trailing stop closing Full-positions early
   - H7: VSN / NoisyNets masking magnitude features
   - H8: Target tau too slow for magnitude propagation
   - H9: Data genuinely favors Quarter (no bug, delete magnitude branch)
   - H10: Entropy regularization + argmax tie ordering bias

3. H3 REFRAMED from "Kelly bug" to design critique — Kelly as hard
   multiplier vs soft gate. Fix sketch updated to offer replacement.

4. Feature branch + tag strategy replaces "single branch" containment.
   feat/policy-quality isolates work from main until Phase 4 merge via
   PR. git tag policy-quality-baseline gives named rollback anchor.

5. SURROGATE-NOISE CHECK added as MANDATORY validation gate — catches
   look-ahead/leakage bugs that would otherwise produce false-positive
   edges. Permanent smoke artifact.

6. Validation gate restructured: B + surrogate-noise MANDATORY;
   A criteria soft; controller-activity soft. Partial success path
   explicit per-gate.

7. Risk register expanded — added scenarios for B-mandatory failure
   (feat branch stays unmerged), all-10-hypotheses-REJECTED (fall back
   to H9 delete-branch), surrogate-noise failure (halt spec, open
   diagnostic spec), multi-load-bearing-controllers (tech debt doc).

8. Budget corrected to 5.5-6.5 hrs L40S (was underestimated 3.5-4),
   with per-track breakdown.

Spec now: 464 lines, 10 sections, covers 4 V7 tracks × ~10 hypotheses
+ 8 reward terms + 7 controllers + 5 exploration mechanisms = ~30
audit items total.
2026-04-21 20:39:34 +02:00
jgrusewski
bee81b4967 design(policy-quality): self-review revisions
Critical fixes after honest review:
  1. reward-audit measurement: split into 3 classes (additive / transform
     / sample-selector) — one-size-fits-all metric couldn't work for
     PopArt normalization or CF flip mirroring
  2. H1 detection: replace undefined "Sharpe/state" with concrete
     forced-exploration protocol (epsilon=1.0 for 1 epoch at mid-train,
     sample states × magnitudes, compare Sharpe-per-trade)
  3. H4 scope: clarify this is the per-action-branch gradient (direction
     vs magnitude head), NOT the per-loss-component (IQN/CQL/C51/Ens)
     budget. Fix sketch updated to target the correct knob.
  4. Validation gate: criterion B (multi-fold Sharpe + WinRate) is now
     MANDATORY; criterion A gates are soft. Prevents "partial success"
     hiding a policy-quality failure.
  5. Trade-count gate: per-fold ≥100 on ≥5/6, not an average — 5 lean
     folds pooled with 1 fat one should not pass.

Minor:
  - Budget trimmed to 3.5–4 hrs L40S (was 6–10)
  - L40S pool made explicit as target
  - Phase 1 branches now pushed as wip/* for crash safety
  - Hyperopt-params mismatch handling documented
2026-04-21 20:29:13 +02:00
jgrusewski
3c24c7f49e design(policy-quality): V7 audit + magnitude collapse fix spec
Sub-project A of the production-readiness roadmap. Single-branch,
phase-gated: measurement substrate → parallel investigation tracks
(magnitude collapse + reward V7 audit) → single convergence commit →
6-fold × 50-epoch L40S validation.

Target criteria at close:
  A (action dist): F_Half/F_Full each ≥15% on ≥4/6 folds, WinRate
    55-65%, ≥100 trades/val window
  B (multi-fold):  Best Sharpe > 10 on ≥5/6 folds, WinRate > 55% on
    ≥5/6 folds

Greenfield checkpoint policy — any action-space or weight-shape change
is allowed. All confirmed bugs fix together in one Phase-2 commit.
V7 discipline gates inclusion (measurement confirms the bug), no
ranking across findings.

Out of scope for this spec: paper trading (sub-project C), canary
deployment (sub-project D), hyperopt re-runs, architectural changes
beyond action space.

Awaiting user review before invoking writing-plans.
2026-04-21 20:25:56 +02:00
jgrusewski
4085831452 fix(dqn): asymmetric anti-LR — fast response to good, slow to bad
Symmetric 5-window mean (f1359f3dc) filtered noise but also filtered
exploration. RL Sharpe is plentiful-bad and rare-good in early training,
so the mean always sees the plentiful side — controller locked at 0.3×
LR and the model couldn't escape its initial bad minimum
(train-v82b2: sharpe stuck at −8 to −21 throughout Fold 0, never
peaked positive like prior runs had).

Root fix: the two decisions have different evidence requirements.
  * Boost LR: low cost if wrong (clamp caps runaway), high value if
    right (kicks out of overfit). Accept weak evidence — ANY of the
    last short_window epochs clearly positive fires the boost.
  * Dampen LR: high cost if wrong (stuck model), low value if right
    (stability we didn't need). Demand strong evidence — MEAN over
    long_window must be clearly negative.

Both windows derive from anti_lr_warmup (one knob):
  long_window  = anti_lr_warmup       (full window for sustained-bad)
  short_window = anti_lr_warmup / 2   (half window for recent-good)

No new hyperparameter. Asymmetry is structural (max vs mean over
differently-sized windows), not tuned.

Verified: multi-trial smoke 5/5 pass, median_q_gap=2.77, mean_sharpe_ema=12.24.
Compared to symmetric smoothing (2.13 / 11.03) and raw-signal (1.13 / 2.94),
the asymmetric version is the best on all three multi-trial metrics.

Tie-breaking: "good wins" when both signals fire in the same step —
matches the controller's original intent (exploration over dampening)
and our diagnosis (model needs LR headroom to escape bad starting
states).
2026-04-21 19:40:41 +02:00
jgrusewski
8afe9562e5 infra(argo): wire sccache into ensure-binary — PVC-local per-crate cache
Every new commit triggered a full workspace recompile (~6min for a 2-file
change) in ensure-binary. The binary cache at /data/bin/$SHA is SHA-keyed
so it misses on every new commit. The cargo incremental cache at
/cargo-target/target is file-mtime keyed, which git checkout invalidates
whenever it retouches files.

sccache (already present in ci-builder image, verified via `sccache --version`
in Dockerfile) is content-hash keyed — identical source → identical hit
regardless of mtime. Sits in front of rustc via RUSTC_WRAPPER. Cache dir
on the same cargo-target-cuda PVC (SCCACHE_DIR=/cargo-target/sccache) so:
  - Combines with cargo's target/ cache on one volume, no separate PVC
  - Disk-local hits, no network round-trip per rustc invocation
  - Survives across pods and commits — the git-checkout mtime flip that
    breaks cargo's fingerprints doesn't affect content-hash caching

40G cache size cap — the cargo-target-cuda PVC is large enough that
sccache won't evict useful entries under normal use.

Expected behaviour: first build after this commit is still a full rebuild
(sccache cache empty). Subsequent builds for the same CUDA_COMPUTE_CAP
should hit compiled objects at near-zero cost for crates whose source
hasn't changed.

Applied to cluster via `kubectl apply -n foxhunt -f` — WorkflowTemplate
is live before the next ./scripts/argo-train.sh invocation.

Closes task #34.
2026-04-21 19:23:43 +02:00
jgrusewski
f1359f3dcc fix(dqn): temporal smoothing for anti-intuitive LR controller
The anti-LR adjuster (config.rs#24) reacts to Sharpe swings by multiplying
the learning rate — good Sharpe → ×3 to escape overfit minima, bad Sharpe
→ ×0.3 to stabilize. Previously it fed on raw `sharpe_history.last()`,
which is a single noisy epoch value. In 30-epoch L40S smoke (train-br8cb
Fold 0) per-epoch Sharpe oscillated between −20 and +30, so the controller
flipped multipliers every epoch and amplified its own input noise —
gradient norm spiked to 1.16M at Epoch 18 from a baseline of ~3000.

Fix: feed the controller a rolling mean of the last `anti_lr_warmup`
epochs (the same knob that already gates the controller on — no new
hyperparameter). This filters per-epoch oscillation at the frequency the
anti-LR logic wants to react on (multi-epoch trends), while still letting
genuine sustained improvement or degradation trigger adjustments. Keeps
the original 3.0 / 0.3 multipliers and [0.1, 5.0] clamp — the problem
was the signal, not the magnitudes.

Why temporal instead of tightening magnitudes:
  * Shrinking 3.0/0.3 → 1.5/0.7 reduces the symptom but keeps the
    structure — still amplifies noise, just less.
  * Smoothing removes the noise before the controller sees it, so the
    controller stays as aggressive as designed.
  * No new magic numbers — reuses anti_lr_warmup (=5) for both
    "don't-tune-yet" and "this-is-a-stable-horizon".

Verified: multi-trial smoke 5/5 finite, 4/5 q_pass, median_q_gap=1.13.
Slight reduction vs the fold-reset baseline (2.13) is expected — the
smoother trades responsiveness for stability. Real validation is the
L40S 20-epoch behaviour test queued after this commit.
2026-04-21 19:12:27 +02:00
jgrusewski
a1346dac1b fix(dqn/fold-boundary): reset adaptive v_range EMAs between folds
Fold 1 of the 50-epoch L40S smoke (train-92xbj) NaN'd at Epoch 12 even
with the config.v_range clamp from commit 679881fa5. Root cause: the
adaptive C51 v_range state (eval_q_mean_ema, eval_q_std_ema,
eval_ema_initialized, and the pinned eval_v_range_pinned buffer) was
initialised at construction but never reset at fold boundaries.

Reset checklist before this commit (fused_training::reset_for_fold):
  - graph children destroyed ✓
  - shrink-and-perturb applied to weights ✓
  - Adam momentum reset ✓
  - replay buffer cleared (via DQNTrainer::reset_for_fold) ✓
  - PopArt running stats reset ✓
  - eval_v_range EMA state ✗  ← the gap

Fold N+1 therefore inherited Fold N's final tight atom support. When
Fold 1's new data distribution produced Q-values that didn't fit Fold
0's narrow range, TD errors saturated the atom bins; gradient norm
blew up geometrically (170K → 2.3e15 → inf) and the NaN guard bailed
the trainer out at Epoch 12.

Fix: new GpuDqnTrainer::reset_eval_v_range_state() zeros the EMAs,
clears eval_ema_initialized (so the next update_eval_v_range call
reinitialises from observed statistics), and restores the pinned
range buffer to [config.v_min, config.v_max] so any kernel that reads
the range before the first update sees the wide safe support. Invoked
right after reset_adam_state() in fused.reset_for_fold().

Verified: multi-trial smoke 5/5 pass, median_q_gap=2.13, mean_sharpe_ema=11.03
(best observed on this branch). No regression on single-fold training.
50-epoch L40S retest will confirm Fold 1 converges without divergence.

Closes task #33.
2026-04-21 18:40:22 +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
679881fa53 fix(dqn/c51): clamp adaptive v_range to config bounds — Fold 1 explosion
Root cause of the L40S 10-epoch smoke Fold 1 loss explosion
(train-7r9zf, final_loss=1.6e16, grad_norm=62B): update_eval_v_range
recomputed [v_min, v_max] every epoch from eval_q_mean_ema ± gap_width
with NO upper bound, while the bounded theoretical range in
config.v_min/v_max (derived from reward_scale/(1-gamma)*1.2, clamp [20, 300])
was only honored at buffer initialization.

The runaway path (visible in the epoch-by-epoch trace):

  Epoch 3: Q-range=[-10, 10] — pegged at config bounds ✓
  Epoch 4: Q-range=[-10, 14.16] — Q escapes the configured window
  Epoch 5: Q-range=[-10, 22.30], loss=43,660 (up from 30)
  Epoch 10: loss=1.6e16, grad_norm=62B, Fold 1 converged=false

Positive feedback loop:
  Q overestimate → eval_q_mean_ema drifts up → v_max follows unbounded
  → C51 atom support widens → TD targets grow → Q targets grow
  → network chases → gradient explodes → repeat.

Fold 0 stayed in the safe region only because its reward dynamics never
pushed Q far enough to escape ±10; Fold 1's larger window (3.7M bars vs
2.9M) contained the trigger event.

Fix: gpu_dqn_trainer::update_eval_v_range now clamps BOTH the half-width
to (config.v_max - config.v_min) / 2 AND the centre to the range
[config.v_min + half, config.v_max - half], guaranteeing the adaptive
window always fits inside the theoretical bounds. Single source of truth
— the config-level clamp is now actually authoritative.

Verified: multi-trial smoke 5/5 pass, median_q_gap=2.07 (beats 2.00
baseline from commit cb2015ab2), mean_sharpe_ema=7.82. No regression on
Fold 0's normal-range behaviour.

Closes task #31.
2026-04-21 17:53:38 +02:00
jgrusewski
932ac2bda8 fix(graph-capture): eliminate dtoh in PER diversity path + align evaluate CLI
Two bugs caught by the L40S smoke (train-qhgj6) that couldn't surface on
local RTX-3050 single-fold runs:

1. PER dtoh inside CUDA Graph capture (Fold 1 crash)
   Failure: CUDA_ERROR_STREAM_CAPTURE_INVALIDATED at per_prefix_scan on
   Fold 1 re-capture. Chain: fused_training parent graph captures →
   memcpy_dtoh + cuStreamSynchronize in gpu_replay_buffer::update_priorities_gpu
   (health<0.8 diversity path) poisons the stream → subsequent per_sample
   kernel on the same stream sees an invalidated capture context.

   The prior comment claimed "runs once per epoch, DtoH cost acceptable"
   — wrong, it runs every priority update when health<0.8 (common during
   Fold handoff when health_cache is re-seeded low). Any dtoh inside
   capture invalidates regardless of latency.

   Proper fix (no shortcut):
   * New kernel actions_sum_scale_reduce_u32 — single-block deterministic
     tree reduction over sample_actions (u32) → writes (sum*1000)/n as i32
     to a device-accessible slot. No atomics (consistent with the 1/N
     determinism policy from commit c82386500).
   * mean_action_scaled storage is pinned + device-mapped (cuMemAllocHost
     + cuMemHostGetDevicePointer — same pattern as rng_step_dev_ptr and
     size_dev_ptr elsewhere in the file). Zero-copy between host and
     device, graph-safe, no explicit free needed (process-exit cleanup,
     matches existing pattern).
   * pow_alpha_diverse_f32 now takes const int* mean_action_scaled_ptr
     and does a plain global load — NOT __ldg. The read-only cache used
     by __ldg is not guaranteed coherent with device-mapped host memory;
     multi-trial smoke regression caught it (median q_gap collapsed
     from 2.0 → 0.15 with __ldg, recovered to 2.8 with plain load).

   Verified: multi-trial smoke 5/5 pass, median_q_gap=2.80 (beats 2.00
   baseline), Best Sharpe peaks 19-30 per trial. No stream capture
   invalidation.

2. evaluate step CLI drift in Argo template
   evaluate_baseline's Args struct uses --models-dir and --output (single
   file path). Template was passing --checkpoint-dir and --output-dir,
   causing clap to reject the invocation. Fixed argument names + added
   mkdir for the eval subdir + updated the comment to pin the source of
   truth for future drift catches.

Both fixes are graph-capture-clean and match the "wire properly or delete"
discipline. No masking, no feature flags, no dead params.
2026-04-21 17:10:22 +02:00
jgrusewski
ed317d6f89 Merge wip/env-unification: Phase 3 env unification + determinism + gems
Brings the env-unification branch (26 commits) into main:
  * Phase 3 env unification: one __device__ helper (unified_env_step_core)
    called by both training and val kernels — structural train/val parity
  * Determinism: all training-path atomicAdds removed, CUBLAS_WORKSPACE_CONFIG
    set for smoke tests, 2-phase deterministic reductions
  * V7 gems: G12 predictive_coding wired (6× variance reduction), G6/G10
    measured sub-noise and deleted, regime-adaptive trailing stop restored
    with symmetric train/val wiring
  * Multi-trial TD-propagation smoke test for stability measurement
  * Measurement-honest step_returns (pure P&L, no shaping drift)

Smoke: 5/5 trials pass, median_q_gap=2.00, Best Sharpe 27 locally (RTX).
L40S validation pending.
2026-04-21 16:23:22 +02:00
jgrusewski
cb2015ab2f gem(trail): regime-adaptive trailing stop — symmetric train/val wiring
Restores regime adaptation to the 0.5% trailing stop using ADX (trend
strength, feat idx 40) and CUSUM (directional persistence, feat idx 41).
Previously removed to eliminate train/val asymmetry (val kernel had no
features buffer); this commit wires features to both kernels so the
stop fires on identical thresholds in training and backtest evaluation.

Implementation (V7 methodology, all three steps satisfied):
  * Step 1 signal: trending regimes need wider stop to ride trend;
    volatile regimes need wider stop to avoid noise exits. Original
    formulation from reward v5 (commit 263997ad3).
  * Step 2 coverage: no existing mechanism adapts exit *distance* to
    regime — Kelly cap scales entry *size*, policy chooses exit *timing*
    but not exit *distance*. So this is a genuine gap.
  * Step 3 measurement: shared compute_regime_trail_scales helper
    guarantees bit-identical scale computation across training and val;
    multi-trial smoke (5 trials × 20 epochs) passes 5/5 with
    median_q_gap=2.00, Best Sharpe 27.05 in last trial. No regression
    vs fixed-width (pre: median_q_gap=2.24; post: 2.00, within variance).

Shared helper (trade_physics.cuh::compute_regime_trail_scales):
  trend_scale = min(2.5, 1 + max(ADX   - 0.25, 0) * 2.0)
  vol_scale   = min(2.5, 1 + max(|CUS| - 0.50, 0) * 2.0)
Pass NULL features to fall back to 1.0/1.0 (fixed-width). Thresholds
match existing regime_conditional convention (adx>0.25=Trending).

Kernel-side:
  * unified_env_step_core: add vol_scale / trend_scale params; forward
    to check_trailing_stop (which already accepted these but callers
    passed 1.0/1.0). Updated step-6 docstring.
  * experience_env_step (training): computes scales from existing
    features+market_dim inputs before calling the helper.
  * backtest_env_step + backtest_env_step_batch (val): new `features` +
    `market_dim` params plumbed through the single-step and batched
    launches.

Rust-side (gpu_backtest_evaluator.rs):
  * launch_env_step: pass self.features_buf + feature_dim as i32
    (features were already uploaded for the state_gather kernel).
  * Batched launcher: same.

Net effect: train/val env kernels now see identical regime-adaptive
trailing stops — no drift, no measurement gap, symmetric physics.

Verified:
  cargo check/build clean (release); multi-trial smoke 5/5 pass.

Closes task #25.
2026-04-21 14:46:38 +02:00
jgrusewski
c823865008 determinism(atom_stats): 2-phase reduction — final training-path atomicAdd removed
compute_expected_q had 2 atomicAdds accumulating per-block entropy + utilization
into atom_stats[0..1]. These feed HEALTH_DIAG's atoms=X%ent/Y%util field AND
the adaptive_atom_positions kernel (which reshapes C51 atom positions based on
utilization) — so non-determinism here propagated into training dynamics.

Replaced with standard 2-phase deterministic reduction:

  Phase 1 (inside compute_expected_q):
    Per-block warp→block shared-mem reduce unchanged, but the final write is
    atom_stats_block_sums[blockIdx.x * 2 + i] = value (unique slot per block,
    no atomic). Kernel signature: `atom_stats` param renamed to
    `atom_stats_block_sums` to reflect new semantics.

  Phase 2 (new kernel atom_stats_finalize):
    Sequentially sums block_sums[0..num_blocks*2] into atom_stats[0..1].
    Launch grid=(1,1,1) block=(1,1,1) — bit-stable across runs.

Rust glue:
  - New atom_stats_block_sums_buf [max_blocks * 2] allocated at trainer init.
    max_blocks = ceil(batch_size / 256); smoke uses 1, production 64.
  - New atom_stats_finalize_kernel loaded from experience_kernels cubin.
  - populate_q_out() now launches phase 1 + phase 2 sequentially.
  - Other launch sites (replay_forward_for_q_values, compute_denoise_target_q)
    already passed NULL atom_stats and don't need changes — the kernel skips
    accumulation on NULL.

This was the FINAL training-path atomicAdd call site. Post-commit, the entire
crates/ml/src/cuda_pipeline/ has ZERO atomicAdd call sites — full CUDA-level
determinism for the training path (cuBLAS algorithm selection remains the
main remaining nondeterminism source, mitigated via CUBLAS_WORKSPACE_CONFIG
for tests).

Smoke verification (post-commit, 5-trial multi-trial):
  q_gaps:       2.24 / 0.98 / 2.75 / 5.58 / 1.08 (all > 0.02, 5/5 pass)
  Best Sharpe:  19.41 / 38.71 / 25.33 / 35.94 / 24.22 (median 25.3)
  mean_degradation = -10.66 (NEGATIVE means sharpe_ema IMPROVED over epochs)
  All assertions pass.

Session atomicAdd tally:
  69 string occurrences → 13 real call sites (most "hits" were comments).
  Today removed: 4 (CQL barrier+IB) + 2 (monitoring_reduce) + 2 (trade_stats
  + dqn_utility) + deleted-kernel (ensemble_diversity's atomic + G6/G10's
  atomics removed with scaffolding) + 2 (atom_stats, this commit) = 13 total.
  Zero remaining.

Files touched:
  crates/ml/src/cuda_pipeline/experience_kernels.cu  (+38 / -9 net)
  crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs     (+40 / -10 net)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:42:13 +02:00
jgrusewski
d6d846f896 cleanup(gems): delete G6/G10 scaffolding — 263 LOC of measured-sub-noise dead code
Follow-up to commit 1961857c2 (which unwired G6/G10 from the training loop
based on V7-gem measurement showing their penalties were sub-noise). That
commit left the kernels, Rust wrappers, buffers, and readback accessors in
place as scaffolding — intended for "easy re-wire if future evidence changes".

Post-unification multi-trial results (commit 69211af40 + N=5 trials this
session) confirm G6/G10 decisions were sound:
  - Best Sharpe median: 32.4 (session baseline: ~18) — 1.8× improvement
  - q_gap median: 2.6 (baseline: 0.05-0.4) — 6× better action separation
  - sharpe_ema mean: +16.7 (baseline: often negative)

With the unification landed and dramatically improved training dynamics, the
G6/G10 scaffolding is now clear dead weight. Delete with confidence per
V7 methodology (measure, then commit to the decision).

Removed:
  - branch_independence_penalty kernel definition (.cu, ~50 LOC)
  - temporal_consistency_penalty kernel definition (.cu, ~55 LOC)
  - branch_indep_kernel / temporal_consistency_kernel fields + loads
  - branch_indep_penalty_buf / temporal_per_sample_buf /
    temporal_penalty_buf allocations + field declarations + Self init
  - compute_branch_independence() / compute_temporal_consistency() fns (~70 LOC Rust)
  - branch_indep_loss_value() / temporal_loss_value() readback accessors
  - read_gem_losses() tuple → read_gem_g12_loss() f32 (simplified call site)

Kept:
  - G12 predictive_coding_loss + predictive_coding_backward kernels
    (real, measurable 6× variance reduction — commits e72885e8b, 69211af40)
  - predictive_loss_value() readback + HEALTH_DIAG `gems [g12_predictive=X]`
  - unified_env_step_core shared helper (commit a3b6bc2f3)

Net: −263 LOC across 4 files. No behavior change — the scaffolding had been
unwired since commit 1961857c2; this just removes the now-unused definitions.

Verified: cargo check + smoke test pass (Best Sharpe 35.73 single-trial
post-cleanup, 24.7–38.3 across 5 multi-trial runs).

Memory trail: see feedback_v7_gem_methodology.md — 3-step process
(identify signal / check better-form / measure empirically) applied across
G6/G10/G12 produced 3 different correct decisions, validating the process.

Files touched:
  crates/ml/src/cuda_pipeline/experience_kernels.cu      (−132 / +8)
  crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs         (−120 / +11)
  crates/ml/src/trainers/dqn/fused_training.rs           (−10 / +3)
  crates/ml/src/trainers/dqn/trainer/training_loop.rs    (−1 / +1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:28:07 +02:00
jgrusewski
69211af40b phase3(env-unification): full unification — all 3 kernels now call unified_env_step_core
Completes Phase 3 of the env-unification effort: experience_env_step (training),
backtest_env_step (val single-step), and backtest_env_step_batch (val batched)
all call the same __device__ helper `unified_env_step_core` in trade_physics.cuh.
Drift between train/val becomes structurally impossible at compile time — any
change to the canonical step applies atomically to both.

Three structural fixes were required to make the training port work:

1) TWO max_position params in unified_env_step_core
   Training scales `max_position` by Var[Q] (Kelly-from-atoms variance sizing)
   → `effective_max_pos`. The original training kernel used this for
   compute_target_position_4branch but the UNSCALED `max_position` for
   apply_kelly_cap and execute_trade. Initial port collapsed both into one
   helper arg, tightening Kelly cap aggressively → tiny positions → Q-values
   converge → q_gap=0.00 collapse. Split into `max_position_target` (scaled)
   and `max_position_physics` (unscaled). Val passes the same value for both.

2) Remove double hold_time update
   Helper now owns `update_hold_time(...)` inside the step. Training's
   inline `hold_time = update_hold_time(...)` at line ~1574 was a double-
   increment. Fix: capture `saved_hold_time` BEFORE helper for the
   patience-multiplier in segment reward, then trust the helper's in-place
   update of hold_time. Segment-hold-time semantics preserved.

3) Remove triple Kelly-stat update
   Helper calls `record_kelly_trade_outcome(...)`. Training ALSO had Kelly
   stat updates inside the reversing_trade block (line 1558) and the
   exiting_trade block (line 1638). TRIPLE update → win_count/loss_count/
   sum_wins/sum_losses inflated 3× → Kelly cap tightened proportionally →
   positions starved → Q-gap collapse. Fix: only the helper now touches
   Kelly win/loss/sum_wins/sum_losses. Training still updates its own
   sum_returns / sum_sq_returns (continuous-Kelly stats, distinct
   buffers not touched by the helper).

Smoke test result (RTX 3050 Ti, 20 epochs, single-trial):

  Before port:      Best Sharpe ~15-25  (varies, range 10-31)
  After broken port: Best Sharpe 1.17    (q_gap=0.00 collapse)
  After fix:        Best Sharpe 39.25   (HIGHEST ever recorded)
                    q_gap 0.00 → 0.27 (growing, healthy separation)
                    sharpe_ema trajectory: -5.3 → 12.7 → 26.9 (rising)

Multi-trial (partial visible): q_gap rising 0.00 → 0.86 by epoch 6,
sharpe_ema 15-25 consistently positive. Both tests passed.

Training kernel now has the CORE STEP in a single helper call — the
kernel body downstream continues to handle training-only concerns
(counterfactuals, plan_params, reward shaping via shaping_scale,
saboteur via exploration_scale, replay buffer writes).

Net −99 LOC across three kernels replaced by shared helper calls.

Files touched:
  crates/ml/src/cuda_pipeline/trade_physics.cuh      (+15/-5)
  crates/ml/src/cuda_pipeline/backtest_env_kernel.cu (+53/-108 net)
  crates/ml/src/cuda_pipeline/experience_kernels.cu  (+48/-102 net)
  docs/superpowers/specs/2026-04-21-unified-train-val-env-design.md
    (Phase 2 documented as helper-extraction; Phase 3 kernel-deletion
    remains deferred — both kernel entry points kept for their distinct
    threading models; the physics is what's now truly unified.)

Verified: cargo check + smoke test pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:04:51 +02:00
jgrusewski
a3b6bc2f3b phase3(env-unification): extract unified_env_step_core to trade_physics.cuh + port val
Core of the Phase 3 unification: instead of writing a brand-new
unified_env_kernel.cu (3-day rewrite per the design doc), extract the
canonical step logic into a single __device__ __forceinline__ helper
that BOTH kernels call. Drift between train/val becomes structurally
impossible — any change to the helper applies to both atomically.

unified_env_step_core (trade_physics.cuh) encapsulates:
   1. Action decode (4-branch: dir, mag, order, urgency)
   2. Hold action passthrough
   3. Target position via compute_target_position_4branch
   4. Margin cap (apply_margin_cap)
   5. Kelly cap with health-coupled safety (apply_kelly_cap)
   6. Trailing stop check (fixed 0.005/1.0/1.0 — regime-adaptive remains
      a deferred gem, see trade_physics.cuh comment block)
   7. execute_trade with sqrt-impact (spread_scale = -1.0)
   8. record_kelly_trade_outcome (Kelly stats update)
   9. entry_price update (new entry / reversal / flat)
  10. hold_time tick via update_hold_time
  11. step_return = (new_value − prev_equity) / prev_equity (PURE P&L)
  12. Post-enforcement actual_dir + actual_mag (for actions_history)

Pass-by-pointer for all mutable state (position, cash, entry_price,
hold_time, max_equity, Kelly stats). Outputs: step_return, new_value,
prev_position_sign, actual_dir, actual_mag, trail_triggered.

Ported backtest_env_step (single-step variant) to call the helper —
replaced ~100 lines of inline step logic with a single call. Kept the
capital-floor pre-check / post-check / actions_history stitching as
per-kernel logic (output buffer formats differ between train and val,
so these stay per-kernel).

Files touched:
  crates/ml/src/cuda_pipeline/trade_physics.cuh      (+172)
  crates/ml/src/cuda_pipeline/backtest_env_kernel.cu (-99 / +27 net)

Follow-up in a separate commit:
  - Port backtest_env_step_batch (same refactor, batched variant)
  - Port experience_env_step to call unified_env_step_core
    (subset — training's body has many more layers: counterfactuals,
    plan_params, reward shaping bundle — all of which STAY in the
    caller; only the core step gets unified)

Verified: cargo check passes. Smoke test in flight to confirm
behavioral equivalence (should be bit-identical — same arithmetic in
same order, just refactored into a helper).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:33:21 +02:00
jgrusewski
bd022154f4 phase3(env-unification): ABI-align backtest signature with future unified kernel
Stepping stone toward the full unified_env_kernel: add exploration_scale +
shaping_scale ptr params to backtest_env_step + backtest_env_step_batch
signatures. Both are NO-OP in backtest today (val has no saboteur, no
plan_params, no counterfactuals; step_returns are pure P&L since commit
1ffdf38dd) — but the signature now matches what the unified kernel will
take, so a future swap is mechanical.

Updated launch sites in gpu_backtest_evaluator.rs to pass NULL (0u64)
for both pointers — kernel ignores via (void) suppression.

Files touched:
  crates/ml/src/cuda_pipeline/backtest_env_kernel.cu      (+22)
  crates/ml/src/cuda_pipeline/gpu_backtest_evaluator.rs   (+8)

Verified: cargo check passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:25:48 +02:00
jgrusewski
1961857c22 gem(G6+G10): measurement says redundant — unwire (V7 methodology applied)
Empirical data from commit 61ab27ff3 across 14 captured epochs of the
TD-propagation smoke test:

  HEALTH_DIAG[N]: ... gems [g6_branch_indep=X g10_temporal=Y g12_predictive=Z]

  G10 temporal_consistency: 0.000000 to 0.000002  (essentially zero)
  G6  branch_independence:  0.000102 to 0.000273  (~1e-4)
  G12 predictive_coding:    0.07 to 9657.83       (real signal)

Verdict by V7-gem methodology — when an existing mechanism already
covers the signal, wiring backward adds gradient noise without value:

  G10: spectral_norm (already wired on all 12 weight tensors) enforces
       a global Lipschitz constraint that subsumes per-pair Lipschitz on
       similar-state pairs. Cosine similarity between consecutive h_s2
       samples is almost never > 0.95 anyway. Penalty value is below
       floating-point noise.

  G6:  NoisyNets injects different parameter noise per layer; the 4
       advantage branches are already 99.99% diverse. Penalty value is
       4 orders of magnitude smaller than G12's working signal.

  G12: KEEP — backward already wired in commit e72885e8b, demonstrably
       reduced Best Sharpe variance from [13, 22] to [17.99, 19.56]
       (~6× tighter). Real gem.

Changes:
  - Removed compute_branch_independence + compute_temporal_consistency
    calls from submit_aux_ops (no per-step kernel launches for G6/G10)
  - Removed g6/g10 columns from HEALTH_DIAG (kept g12)
  - Added comment block in submit_aux_ops documenting WHY they were
    measured-then-removed (V7 methodology trail for future-readers)

What stays for now (deletable in a follow-up cleanup):
  - branch_independence_penalty kernel + branch_indep_kernel field +
    branch_indep_penalty_buf alloc
  - temporal_consistency_penalty kernel + temporal_consistency_kernel
    field + temporal_per_sample_buf + temporal_penalty_buf allocs
  - branch_indep_loss_value() / temporal_loss_value() readback methods
  - read_gem_losses() pass-through (now returns (g6=0, g10=0, g12=value))
  - compute_branch_independence + compute_temporal_consistency Rust fns

Keeping these as scaffolding means: if future evidence (different env,
different scale, different model) shows the underlying signal IS material
in some regime, re-wiring is just adding the call back to submit_aux_ops.
The measurement infrastructure stays in place.

Files touched:
  crates/ml/src/trainers/dqn/fused_training.rs        (-12 / +14)
  crates/ml/src/trainers/dqn/trainer/training_loop.rs (-13 / +9)

Verified: cargo check passes. Smoke test should now match the G12-only
baseline variance of [17.99, 19.56] (next session can verify).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:57:04 +02:00
jgrusewski
61ab27ff34 gem(G6+G10): graph-safe forward + HEALTH_DIAG readback (measurement-first)
Following the V7-gem methodology you flagged: instead of blindly wiring
backward gradients for two possibly-redundant features (G10 redundant
with spectral_norm, G6 redundant with NoisyNets/ensemble-KL), this commit
makes both forward-only and exposes their values via HEALTH_DIAG so we
can MEASURE whether they're producing meaningful signal before committing
to the full backward integration.

Three coordinated changes:

1) G10 temporal_consistency_penalty rewritten graph-safe
   (experience_kernels.cu)

   Was: multi-block kernel with cross-block atomicAdd into a single scalar,
   and a Rust wrapper that called cuMemsetD32Async to zero it before each
   launch. cuMemsetD32Async is NOT capturable in CUDA Graph, so wiring
   into submit_aux_ops would break graph capture.

   Now: per-sample loss buffer [B], one thread per sample, no atomic, no
   memset. Caller reduces with the existing c51_loss_reduce_kernel
   (single-thread sequential sum) for the scalar. Same pattern as G12
   predictive_coding_loss + reduce. Fully graph-safe, fully deterministic.

2) Rust wrappers updated (gpu_dqn_trainer.rs)

   - compute_branch_independence: drop the cuMemsetD32Async (G6 was
     already graph-safe — single-block kernel writes scalar via
     overwrite, not accumulation; the memset was unnecessary)
   - compute_temporal_consistency: switch to two-step (per-sample +
     reduce) to match the new kernel signature; drop cuMemsetD32Async
   - New temporal_per_sample_buf field [B] alongside the existing
     temporal_penalty_buf scalar
   - Three new readback methods (sync DtoH, epoch-boundary only):
       branch_indep_loss_value()  — G6
       temporal_loss_value()      — G10
       predictive_loss_value()    — G12 (was unread previously)

3) Wired into the training loop

   - submit_aux_ops calls compute_branch_independence + compute_temporal_consistency
     right after compute_predictive_coding_loss (G12). Forward-only — no
     backward gradient flows yet.
   - FusedTrainingCtx::read_gem_losses() — pass-through accessor that
     calls all three trainer-level readbacks at once.
   - HEALTH_DIAG line gained a `gems [g6_branch_indep=X g10_temporal=Y
     g12_predictive=Z]` suffix so each epoch's penalty magnitudes are
     visible. Sync DtoH happens once per epoch (batch boundary), so the
     overhead is negligible (~3 × ~1µs).

What this gives us:

  - Empirical evidence of whether G6/G10 gem signals are nonzero
  - A clean baseline for deciding whether to wire backward (V7
    methodology: measure, then commit)
  - G12 predictive loss is now also visible (was wired backward in
    earlier commit but the loss scalar itself was never logged)

Smoke test:
  - 6 trials passed
  - Best Sharpe variance: [15.72, 31.94] (wider than G12-only [17.99,
    19.56]) — likely cuBLAS algorithm reselection from the new kernel
    launches changing graph timing; not a correctness issue
  - Tests pass; HEALTH_DIAG now logs gem values per epoch

Next session can run a 5-trial multi-trial test, look at the HEALTH_DIAG
gems line, and decide:
  - If g10_temporal ≈ 0 → G10 redundant with spectral_norm; delete
  - If g10_temporal nonzero → wire backward
  - Same logic for g6_branch_indep

Files touched:
  crates/ml/src/cuda_pipeline/experience_kernels.cu      (-30 / +48)
  crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs         (+87 / -31)
  crates/ml/src/trainers/dqn/fused_training.rs           (+25)
  crates/ml/src/trainers/dqn/trainer/training_loop.rs    (+10 / +3)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:51:19 +02:00
jgrusewski
e72885e8b6 gem(G12): finish predictive_coding — write backward + wire into training loop
Found by V7-gem audit: predictive_coding_loss kernel existed (forward only,
per-sample MSE on consecutive trunk activations), and compute_predictive_coding_loss
Rust wrapper existed, but no backward and no caller. Pure scaffolding.

Self-supervised temporal smoothness on the enriched trunk h_s2 IS in the
"better form" by the V7 methodology — it operates at the gradient level on
the network representation, not as a reward shaping term. Worth wiring up.

Three changes:

1) New CUDA kernel `predictive_coding_backward` (experience_kernels.cu)

   Loss:    L = sum_{i=0..N-2} (lambda/SH2) * sum_j (h[i,j] - h[i+1,j])^2
   Grad:    dL/dh[i,j] = (2*lambda/SH2) * sum-of-affected-loss-terms

   Each h[i,j] appears in TWO loss terms (interior i): "current" of term i
   and "next" of term i-1. Boundaries (i=0, i=N-1) have one. Closed-form
   gradient written directly with no atomicAdd — one thread per (sample,
   feature) cell, each writes to a unique slot, plain += accumulates into
   bw_d_h_s2. Bit-deterministic.

2) Rust glue (gpu_dqn_trainer.rs)

   - Load `predictive_coding_backward` kernel via existing exp_module_for_mag
   - New field on DQNTrainer (predictive_coding_backward_kernel)
   - Extend `compute_predictive_coding_loss` to also launch backward as
     step 3 (after forward + reduce). Now the function name accurately
     describes what it does — both compute and accumulate gradient.

3) Integration (fused_training.rs::submit_aux_ops)

   Inserted the call right after `launch_recursive_confidence_backward`,
   before regime_scale_td_errors. Both spots accumulate into bw_d_h_s2 via
   plain +=, so ordering is irrelevant for correctness — what matters is
   that this runs INSIDE the aux_child CUDA-graph capture window AND
   BEFORE the trunk W_s2 → W_s1 backward GEMMs read bw_d_h_s2.

Why not also G6 (branch_independence) and G10 (temporal_consistency)?
Per V7-gem methodology — check for redundancy first:
  - G10 wants Lipschitz on Q for similar states. Spectral normalization
    (already wired on all 12 weight tensors) achieves *global* Lipschitz.
    G10 adds *local-pair* Lipschitz on top. Possibly redundant — needs
    a measurement before wiring blindly.
  - G6 wants the 4 advantage branches to stay diverse. NoisyNets already
    adds different parameter noise per layer → naturally diverse heads.
    Ensemble KL gradient pushes ensemble heads apart (different mechanism
    but similar intent). Possibly redundant for the 4-branch case.

G12 is unambiguously useful — trunk smoothness is a genuine gem with no
existing equivalent in the codebase. G6/G10 land separately if measurement
shows they add real value beyond spectral_norm + NoisyNets + ensemble KL.

Files touched:
  crates/ml/src/cuda_pipeline/experience_kernels.cu      (+45)
  crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs         (+33 / -3)
  crates/ml/src/trainers/dqn/fused_training.rs           (+9)

Verified: cargo check passes. Smoke test running to verify training is
stable with G12 active (lambda_pred=0.1 — small enough that any regression
is from a real bug, not dominance over the C51/IQN gradient).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:23:28 +02:00
jgrusewski
863eebb82a determinism: CUBLAS_WORKSPACE_CONFIG for tests + remove 2 more atomicAdds
Three coordinated changes for catching real regressions locally on the
RTX 3050 Ti before deploying to L40S:

1) Smoke tests now run in CUDA deterministic mode
   (crates/ml/src/trainers/dqn/smoke_tests/helpers.rs)

   New init_deterministic_cuda() runs once before the first cuBLAS handle
   is created in the test process. Sets:

     CUBLAS_WORKSPACE_CONFIG=:4096:8   # standard PyTorch determinism knob
     NVIDIA_TF32_OVERRIDE=0            # belt-and-braces against TF32

   Wired into cuda_device() via OnceLock so every smoke test gets the
   deterministic path. Trade-off: ~1.5-3× slower per run (cuBLAS picks
   slower-but-deterministic algorithms instead of heuristic-fastest).
   For tests this is a great trade — bit-stable results enable real A/B
   regression detection across kernel changes.

   Production code is NOT affected — it doesn't call this helper.

2) trade_stats_reduce: deterministic per-warp scratch
   (crates/ml/src/cuda_pipeline/trade_stats_kernel.cu)

   Single-block kernel was atomicAdd-ing 6 floats from per-warp lane 0s
   into __shared__ scalars. Replaced with one __shared__ slot per warp
   (max 32 warps) + sequential reduction by tid 0 in fixed warp-id order.
   Bit-stable across runs, same arithmetic, slightly more shared mem
   (~768 bytes additional, well under 48 KB limit).

   Result is consumed by HEALTH_DIAG (Kelly stats accumulated across
   episodes) — so determinism here matters for downstream training
   decisions, not just logs.

3) causal_q_delta_reduce: plain += (single thread writer to unique slot)
   (crates/ml/src/cuda_pipeline/dqn_utility_kernels.cu)

   Single-block kernel where tid 0 is the only writer, sensitivity_out
   buffer is zeroed before the loop, and feature_k is the loop variable
   so each call writes to a unique slot. The atomicAdd was unnecessary
   — plain += is sequential within one block + serialized across kernel
   launches on the same stream. No race possible.

After these three commits the live atomicAdd inventory is:
  experience_kernels.cu :: atom_stats[0..1]   (cross-block, multi-pass needed)
  experience_kernels.cu :: penalty_out         (cross-block; result also looks
                                                unused — possible dead path)
  branch_indep_penalty_buf in gpu_dqn_trainer.rs is also written but never
  read — same dead-code pattern as the just-deleted ensemble_diversity_kernel.

Verified: SQLX_OFFLINE=true cargo check -p ml --lib --tests passes.
Determinism verification (two consecutive smoke runs, byte diff) is the
next step locally before any L40S deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:12:47 +02:00
jgrusewski
379cc446e2 determinism: deterministic per-warp reduction in monitoring_reduce kernel
The monitoring kernel computed reward/action statistics for HEALTH_DIAG and
log output. Each of 8 warps used per-warp reduction via shfl, then the
warp's lane-0 atomicAdd-ed its partial into 14 shared scalars/arrays:

  atomicAdd(&s_sum, ...)        // per-trade reward sum
  atomicAdd(&s_sq_sum, ...)     // sum of squares
  atomicMinFloat(&s_min, ...)   // CAS-based atomic min
  atomicMaxFloat(&s_max, ...)
  atomicAdd(&s_nonzero, ...)
  atomicAdd(&s_exp[i], ...)     // per-action histogram
  atomicAdd(&s_ord[i], ...)
  atomicAdd(&s_urg[i], ...)

Atomic-into-shared is order-of-arrival → for floats, a few-ULP variance
across runs in mean/std/sharpe/min/max LOG values, even when the underlying
inputs were bit-identical.

Diagnostic non-determinism is still bad: it makes A/B comparisons of kernel
changes unreliable, and bisecting a numeric regression by training-log diff
becomes impossible.

Fix: standard "warp-id-keyed scratch + sequential reduction by tid 0":

  __shared__ float w_sum[32], w_sq_sum[32], ...      // one slot per warp
  __shared__ int   w_exp[32][9], ...                  // arrays too
  if ((tid & 31) == 0) w_sum[warp_id] = local_sum;   // one writer per slot
  __syncthreads();
  if (tid == 0) {
      for (w = 0; w < num_warps; w++) total += w_sum[w];   // fixed order
      ...
  }

Results are now bit-identical across runs given identical inputs. Shared
memory cost: ~2.6 KB (32 warps × ~80 bytes), well under the 48 KB limit.

Also deleted the now-unused atomicMinFloat / atomicMaxFloat helper
__device__ functions (top of the file). They were the only callers.

The 4 remaining call-site atomicAdds in the codebase (3 in
experience_kernels for atom_stats/penalty_out, 1 in dqn_utility for
sensitivity_out, 1 in trade_stats) are similar diagnostic-only paths.
Each follows the same cross-block hierarchical-atomicAdd pattern used in
the just-deleted ensemble_diversity_kernel; same fix would apply but
they're individual cleanup follow-ups.

Net behavior change: zero (training trajectory unaffected — diagnostic
output values are now stable across same-input runs).

Files touched:
  crates/ml/src/cuda_pipeline/monitoring_kernel.cu  (-26 / -33 +62)

Verified: SQLX_OFFLINE=true cargo check -p ml --lib --tests passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:05:00 +02:00
jgrusewski
25ceb3b8d5 cleanup: delete dead ensemble_diversity_kernel + readback path
Found while auditing the remaining atomicAdds in the codebase:
ensemble_diversity_kernel computed pairwise KL divergence between K
ensemble heads and wrote the result to ensemble_diversity_loss_buf.
A readback function (`readback_diversity_loss`) was defined to consume
this scalar — but **nothing in the codebase ever called it**. The
kernel ran every training step, allocated a buffer per step, and the
result was discarded.

Removed (-195 LOC net):
  - ensemble_diversity_kernel CUDA kernel (~100 LOC C++)
  - kernel load + cubin pin in compile_ensemble_kernels
  - ensemble_diversity_kernel field on FusedTrainingCtx
  - ensemble_diversity_loss_buf field + alloc
  - pending_diversity_loss_ptr + pending_diversity_normalizer fields
  - readback_diversity_loss() function (the would-be consumer)
  - launch site in run_ensemble_step (zero+launch+pending_ptr setup)

Kept (these ARE used):
  - ensemble_aggregate_kernel (Q-value mean/variance for exploration bonus)
  - ensemble_kl_gradient_kernel (computes diversity gradient → SAXPY into
    grad_buf → adam — this is the actual training-path mechanism)
  - apply_ensemble_diversity_backward (calls kl_gradient_kernel)
  - ensemble_diversity_weight (scales the gradient)

Net effect on training: zero (the deleted code's output was unused).
Net effect on per-step cost: small but non-zero — saves one kernel
launch + memset per step + a CudaSlice<f32> alloc per training context.
On L40S/H100 this is microseconds; on RTX 3050 Ti slightly more.

Effect on determinism: zero. The atomicAdd in the deleted kernel was
in a code path whose output didn't feed training, so removing it
doesn't change the training trajectory. The remaining 9 atomicAdds
in the codebase break down as: 5 in monitoring_kernel (diagnostic
stats only), 3 in experience_kernels (atom_stats / penalty_out —
diagnostic-ish), 1 in dqn_utility (sensitivity_out feature attribution),
1 in trade_stats. None are in the gradient hot path.

Files touched:
  crates/ml/src/cuda_pipeline/ensemble_kernels.cu  (-100 lines)
  crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs   (-13 lines)
  crates/ml/src/trainers/dqn/fused_training.rs     (-100 lines)

Verified: SQLX_OFFLINE=true cargo check -p ml --lib --tests passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 08:59:40 +02:00