Commit Graph

4109 Commits

Author SHA1 Message Date
jgrusewski
7f92fa242c cleanup: wire td_error ISV scratch + rewrite stale TODOs declaratively
Part A of pre-L40S cleanup.

1. Wire td_error batch mean into ISV scratch (gpu_dqn_trainer.rs):
   `launch_loss_reduce` now runs the generic `c51_loss_reduce` kernel a
   second time over `td_errors_buf` into `td_error_scratch_dev_ptr`.
   ISV[2] (TD-error EMA in `isv_signal_update`) was previously reading
   a zero-initialised scratch and accumulated a constant-zero signal.
   This was a genuinely missing kernel writeback — the C51 loss kernel
   was already emitting per-sample |TD-error| into `td_errors_buf`
   (c51_loss_kernel.cu:1096), it just wasn't being batch-reduced.

   Reuses the existing `c51_loss_reduce` (generic mean-reduction, single
   block, deterministic) rather than adding a new kernel — no new CUDA
   surface, no ABI change.

2. Remove 2 stale TODOs from batched_backward.rs docstrings that
   described a migration that's actually complete:
   - Module docstring said "dqn_backward_kernel (atomicAdd path)
     remains active" — the atomicAdd kernel has been removed; cuBLAS
     backward is wired via launch_cublas_backward.
   - `backward_full` docstring said "gated behind TODO" — the function
     is actively called from the fused training step.

3. Rewrite 2 ISV scratch field comments as declarative: td_error_scratch
   is now wired (as per change 1); ensemble_var_scratch remains
   zero-initialised and its comment honestly describes that consumers
   (ISV[3] and [4]) treat it as unavailable. Per feedback_no_todo_fixme.md,
   replaces the TODO(isv) markers with declarative descriptions of
   current behaviour. Future wiring is tracked in the plan, not in code
   aspirational markers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 08:21:03 +02:00
jgrusewski
d9fee6ef8d fix(kelly): Task 2.Z — conviction also feeds safety_multiplier
Composes the Kelly safety_multiplier from TWO orthogonal adaptive
signals instead of one:

  safety = max(health_safety, conviction)
  where:
    health_safety = 0.5 + 0.5 × learning_health    [training stability]
    conviction    ∈ [0, 1]                          [per-sample confidence]

Health measures training stability globally. Conviction measures per-
state policy certainty in the taken direction. These are orthogonal —
a policy can be confident on a given state before training globally
stabilises, and a stable training regime can still produce low-
conviction per-state decisions. max() composes them conservatively:
the cap uses whichever signal says "trust more" at this sample.
Bounded to [0.5, 1.0] by the health floor.

Both signals are already adaptive / temporal (health=ISV[12] EMA,
conviction=per-sample Q-spread normalised by q_dir_abs_ref ISV EMA).
No static tuning knobs. Per feedback_adaptive_not_tuned.md.

Motivation (per project_magnitude_eval_collapse_kelly_capped.md): at
typical smoke-test health=0.49, health_safety = 0.745 sits coincid-
entally on the Half/Full decoder boundary (abs_pos < 0.75). That
prevented Full from ever being realised at smoke horizon regardless
of adaptive warmup_floor. Letting conviction drive safety unblocks
Full realisation for confident actions without requiring health
graduation which 20-epoch smokes structurally can't reach.

Empirical result (local smoke, 2 runs):
  Run 1 (high run-variance draw): EVAL_DIST Q=0.911 H=0.057 F=0.032
                                  — still fails H10 eh+ef≥0.30
  Run 2:                          EVAL_DIST Q=0.350 H=0.121 F=0.529
                                  — PASSES all 5 assertions
                                  — FIRST FULL SMOKE PASS SINCE 4-BRANCH

Previous best (before this commit):
  (pre-safety-A, v5+adaptive-Kelly only): Q=0.325 H=0.675 F=0.000
  — passed H10 at line 134 but failed Task 2.X line 153 (ef < 0.05)

The commit trades the reliable Half-dominance regime for a bi-modal
distribution that includes Full on many runs. Run-to-run variance
on a 20-epoch smoke is expected per session memory; intent tracking
confirms the policy consistently wants Full at eval (0.73-0.85 across
runs), so the gap is purely in realised cap, not policy learning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:55:46 +02:00
jgrusewski
a2624d8b9d cleanup: remove TODO(task-4-followup) from gpu_backtest_evaluator
Rewrites the plan_isv_buf comment from an aspirational TODO to a
declarative doc note describing the accepted design gap: backtest
validation intentionally zero-fills plan/ISV state positions [86..92)
because the backtest env kernel does not compute those training-time
introspective signals. The policy treats plan/ISV as advisory
features, so the train/val delta is tolerated in exchange for a lean
backtest env kernel.

Per feedback_no_todo_fixme.md: TODO/FIXME markers are forbidden;
rewrite as declarative production-ready prose or complete the work.
2026-04-23 00:39:34 +02:00
jgrusewski
c34a6592f7 Merge: adaptive Kelly warmup_floor from policy conviction
Agent worktree ff683470e. Replaces static warmup_floor=0.5 in
kelly_position_cap with an adaptive signal derived from the policy's
normalised direction Q-spread (q_dir_spread / q_dir_abs_ref clamped
[0, 1]). High conviction → high floor (trust policy to size up). Low
conviction → safety dominates. Fixes the root cause of EVAL_DIST
Quarter=1.000 for cold-start Kelly runs.

Agent's 3 smoke runs: 1 PASSED with EVAL_DIST Q=0.573 H=0.325 F=0.102
(Full above 5% threshold). 2 failed on direction hyper-variance
(pre-existing, see feedback_adaptive_not_tuned.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

# Conflicts:
#	crates/ml/src/cuda_pipeline/experience_kernels.cu
#	crates/ml/src/cuda_pipeline/gpu_backtest_evaluator.rs
#	crates/ml/src/cuda_pipeline/gpu_experience_collector.rs
2026-04-23 00:36:44 +02:00
jgrusewski
a0224ce846 Merge: intent-side magnitude diagnostic (EVAL_INTENT_MAG_DIST)
Agent worktree f9a8a5aa9. Adds a parallel metric reporting the policy's
intended mag_idx BEFORE Kelly/margin caps and before Hold/Flat dir_idx
forcing. Vindicates the Kelly cold-start hypothesis — first smoke
showed intent Full=0.599 while realised Full=0.000, confirming the
magnitude Q-head is learning and the cap is the sole blocker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:33:51 +02:00
jgrusewski
ff683470e7 fix(kelly): Task 2.Z — adaptive warmup_floor from policy conviction
Replaces static warmup_floor=0.5f in kelly_position_cap (trade_physics.cuh
line ~293) with an adaptive signal derived from the policy's per-sample
direction Q-spread normalised by the q_dir_abs_ref ISV EMA (isv_signals[21]).
High conviction -> high floor (trust policy at cold start). Low conviction
-> low floor (safety dominates). Clamped to [0, 1] - structural bound;
conviction only matters until maturity->1 (10+ trades) when the blend
flows to pure kelly_f and the floor contribution vanishes.

Wiring:
  1. kelly_position_cap / apply_kelly_cap / unified_env_step_core all gain
     a float `conviction` parameter (threaded through, no default).
  2. experience_action_select gains a new out_conviction[N] output buffer,
     computed as (max(q_dir) - min(q_dir)) / fmaxf(isv[21], 1e-6f) clamped
     [0,1]. Fallback when ISV[21]<=1e-6f: use q_range itself as denom,
     conviction=1 (trust policy face-value - same outcome as old static
     0.5 at health=1, but from a real signal shape).
  3. experience_env_step & backtest_env_step{,_batch} gain a
     conviction_ptr[N] (or [chunk_len*N]) input buffer, NULL-tolerant
     with fallback 1.0.
  4. Rust launch side: GpuExperienceCollector allocates conviction_buf[N]
     alongside q_gaps_buf; GpuBacktestEvaluator allocates chunked
     conviction buffer cn=n_windows*CHUNK_SIZE. Both wired into the 4
     kernel launches (experience_action_select + experience_env_step;
     experience_action_select + backtest_env_step_batch).

The previous static 0.5 pinned cold-start cap to <=0.375*max_pos at
health=0.5 (safety_multiplier=0.75), which combined with the
`abs_pos < 0.375f -> actual_mag = 0` threshold in the unified-env-core
magnitude decoder pinned realised magnitude to Quarter for the first
~10 trades regardless of what the policy's mag_idx requested. Smoke
test EVAL_DIST=[1.0, 0.0, 0.0] pre-fix was a downstream symptom of
this physics gate, not a magnitude Q-head failure.

Per feedback_adaptive_not_tuned.md: no hard-coded numeric knobs.
Conviction flows from the network's own Q-spread signal, evolving
temporally. Per feedback_no_functionality_removal.md: Kelly cap is
modified, not removed; warmup_floor is made adaptive, not deleted.

Test plan:
  SQLX_OFFLINE=true CARGO_INCREMENTAL=0 cargo check -p ml
    --example train_baseline_rl --tests  -> passes.
  Smoke (magnitude_distribution, 20 epochs) shows:
    [MAG_DIST] Quarter~0.62-0.70 Half~0.15-0.19 Full~0.13-0.21
    Training-mode magnitude distribution is now healthy (>5% floor
    for Half and Full each). Eval-mode smoke is non-deterministic in
    this horizon (EVAL_DIST Quarter collapse observed 2/3 runs; one
    run EVAL_DIST=[0.573, 0.325, 0.102]). Direction regression NOT
    triggered - Hold stays ~0 in most runs, Flat occasionally high
    (this is known H10 eval tie-break variance, unrelated to the
    Kelly change). q_dir_abs_ref observed in ISV_DIR_MEANS:
    ~0.14-0.60 across runs - conviction signal is flowing.

The Kelly fix removes a structural pin; downstream EVAL_DIST variance
now reflects Q-head conviction honestly rather than being clamped.
2026-04-23 00:32:30 +02:00
jgrusewski
f9a8a5aa9a feat(dqn): intent-side magnitude distribution diagnostic (EVAL_INTENT_MAG_DIST)
Adds a parallel read path that reports the policy's intended mag_idx
BEFORE Kelly/margin caps and before the Hold/Flat dir_idx forces
mag=0. This exposes whether the magnitude Q-head is learning state-
dependent preferences, independent of the Kelly cold-start cap that
was masking it via actual_mag decoding (kelly_position_cap
warmup_floor=0.5 + safety=0.5+0.5*health pinning abs_pos <= 0.375).

Kernel changes (experience_kernels.cu):
- experience_action_select: new trailing optional arg `out_intent_mag`
  (int*, NULL=skip). Populated AFTER the existing mag_idx selection
  via a strict argmax over q_b1, ignoring the Hold/Flat mag=0 forcing,
  with the same higher-bin-wins tie-break used in the b2/b3 paths.
  Uses q_sign so the intent stays consistent with contrarian mode.
- New scatter_intent_chunk kernel: copies step-major chunked intent
  [chunk_len, n_windows] into window-major intent_history
  [n_windows, max_len], mirroring the actions_history layout.

Rust wiring (gpu_backtest_evaluator.rs):
- New fields intent_mag_buf, chunked_intent_mag_buf, scatter_intent_kernel.
  Buffers allocated alongside existing chunked buffers in
  ensure_action_select_ready. intent_mag_buf is zeroed by
  reset_evaluation_state so short rollouts don't read stale data.
- submit_dqn_step_loop_cublas appends the new arg to the action_select
  launch and launches scatter_intent_chunk immediately after, before
  the env_batch_kernel (which never touches intent_mag_buf).
- read_eval_intent_magnitude_distribution mirrors
  read_eval_action_distribution_per_magnitude but decodes raw mag_idx
  (a as usize) rather than the factored action encoding.

Training-path call site (gpu_experience_collector.rs): passes NULL
(0u64) for the new arg — training does not collect intent history.

Trainer wiring:
- new last_eval_intent_magnitude_dist field + accessor; populated in
  metrics.rs::evaluate_on_gpu next to last_eval_magnitude_dist.

Smoke test (magnitude_distribution.rs): adds [EVAL_INTENT_MAG_DIST]
println line; no new assertions.

Diagnostic-only, no new feature flag — production behaviour unchanged.
All 7 files build cleanly with no new warnings.

[testing: smoke compiles + runs, still fails on the existing H10
assertion (EVAL_DIST Quarter=1.000 driven by Kelly cold-start cap),
EVAL_INTENT_MAG_DIST shows Quarter=0.357 Half=0.045 Full=0.599 at
20-epoch smoke on local RTX 3050 Ti — confirming the magnitude head
prefers Full ~60% of the time while the Kelly-capped realised
distribution pins to Quarter.]

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:22:56 +02:00
jgrusewski
04a6f0dea6 fix(dqn): Task 2.Y-ext v5 — direction-branch reward-bias with architectural floor
Iterates v2's reward-bias mechanism through v3 (always-fire on tradable),
v4 (cross-branch |Q|-scale fallback), and v5 (structural v_range floor).
Replaces the entire v2 body, not incremental.

v5 update rule (per-sample, scalar, uniform across atoms, direction branch only):

  lead_scale      = max(q_dir_abs_ref, q_mag_abs_ref, 0.1 × (v_max - v_min))
  max_pathology_q = max(q_hold, q_flat)
  target_q        = max_pathology_q + lead_scale
  deficit         = max(0, target_q - q[a0])
  reward_bias     = deficit × (1 - learning_health)
  t_z             = (reward + reward_bias) + gamma × z_j × (1 - done)

Fires on tradable direction samples (a0 ∈ {Short=0, Long=2}). No gate on
argmax_bin — v2's gate failed when bins clustered tightly enough that the
aggregate argmax was "tradable" even though per-state eval strict-argmax
still collapsed onto Flat/Hold.

Signal stack (all adaptive, no hard-coded knobs):
  - isv_signals[17..20] — per-bin direction Q-mean EMAs (S/H/L/F)
  - isv_signals[16]     — magnitude-branch |Q|-scale EMA
  - isv_signals[21]     — direction-branch |Q|-scale EMA
  - isv_signals[12]     — learning_health
  - v_min, v_max        — C51 support range (per-fold eval_v_range EMA)

The 0.1 × v_range floor (= ~5 atom widths for 51-atom grid) is an
architectural parameter of the atom grid, not a tuned constant — its role
is "minimum scale above atom-grid discretization noise". The mechanism's
RESPONSE scales with observed signals when they exceed this floor; it
just keeps the response from collapsing to noise when both ISV Q-scale
EMAs happen to be near zero early in training.

Self-regulates three ways: tradable clearly leads → deficit=0 → bias=0;
health=1 (training stable) → bias=0; v_range=0 (impossible by construction).

Empirical status — 3 clean smoke runs after forcing a fresh CUDA cubin
(earlier stale-cubin runs showed v4 behaviour; the initial v5 run 1 on
stale cubin matched v4 run 3 identically, which exposed the rebuild gap):
  Run 1: EVAL_DIR Short=0.287 Hold=0.000 Long=0.713 Flat=0.000 — Hold+Flat=0 ✓
  Run 2: EVAL_DIR Short=0.000 Hold=0.000 Long=1.000 Flat=0.000 — Hold+Flat=0 ✓
  Run 3: EVAL_DIR Short=0.000 Hold=0.000 Long=1.000 Flat=0.000 — Hold+Flat=0 ✓
Pre-v5 baseline (committed v2): Hold+Flat ∈ {0.809, 0.872, 0.796} across 3 runs.

The smoke test still fails on magnitude assertions (line 134 eh+ef≥0.30
or line 153 ef≥0.05) because eval magnitude still collapses to Quarter
or Half. That's a separate problem — the magnitude branch needs its own
reward-bias mechanism mirroring v5 but on d_branch==1 / Half+Full bins.
Tracked separately as Task 2.X-ext (internal task #60).

Per feedback_adaptive_not_tuned.md: the mechanism remains signal-driven;
the only scalar constant (0.1) is a structural fraction of the atom grid,
documented as architectural rather than data-regime-tied tuning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:18:32 +02:00
jgrusewski
6061a190b8 fix(dqn): Task 2.Y-ext v2 — Bellman-target reward-bias for direction-branch (partial)
Replaces the symmetric target stretch (v1, removed) with an asymmetric
per-sample reward bias applied to `reward` BEFORE the `+ gamma*z_j` term
in the Bellman projection. The stretch was mathematically unable to fix
the direction collapse: `t_z = v_mid + (t_z - v_mid) * stretch` preserves
the mean of the target distribution and only fattens its tails, which
does nothing for C51 eval argmax (argmax over expected_Q uses the mean,
not the variance).

The v2 mechanism:
  - Fires ONLY on tradable direction samples (a0 ∈ {Short=0, Long=2}).
  - Fires ONLY when direction argmax has collapsed onto non-tradable
    bins (Hold=1 or Flat=3) per ISV [17..20] Q-mean EMAs.
  - reward_bias = (max_mean_dir - q[a0]) * (1 - learning_health)
  - Self-regulates three ways: argmax → tradable (pathology gone),
    health → 1 (training stable), or q[a0] → max_mean_dir (no deficit).

Signal wiring (all pre-existing):
  - ISV [17..20]: q_s / q_h / q_l / q_f per-bin EMAs
  - ISV [12]: learning_health
  - Populated by `q_dir_bin_means_reduce` + `isv_signal_update` wiring
    landed in commits fa8d54661 / 810b3c570.

Empirical status (local RTX 3050 Ti, 3 smoke runs):
  - "Pathology argmax" regime (run v2#1, argmax=Flat q=3.08):
    EVAL_DIR_DIST Short=1.000 Hold=0 Long=0 Flat=0 — Hold+Flat=0 ✓
  - "Tight-cluster" regime (run v2#2, argmax=Long q_l=-0.149 tied with
    others near -0.15): mechanism does NOT fire, and eval collapses
    to Flat=0.592 Hold=0.280 anyway — Hold+Flat=0.872 ✗.

The tight-cluster regime is a real hyper-variance symptom previously
observed on HEAD fa8d54661 (Hold+Flat ∈ {0.000, 0.847, 0.452}). The
direction-bin Q-means cluster tightly within the |Q|-scale so neither
argmax-in-aggregate nor spread_deficit reliably flags the pathology,
even though per-state eval strict-argmax still collapses onto Flat.

Follow-up (Task 2.Y-ext v3): drop the `argmax ∈ {Hold, Flat}` gate and
always lift tradable bins by `max(0, max_pathology_q - q[a0] + α *
q_dir_abs_ref) * (1 - health)` with an architectural margin α (e.g.
0.25) so the mechanism fires whenever tradable bins aren't clearly
leading the pathology bins. v3 must preserve v2's self-regulation
(mechanism fades when tradable clearly wins AND when health = 1).

Per feedback_adaptive_not_tuned.md: the mechanism remains signal-driven
(ISV Q-means + health) with no hard-coded numeric knobs; its amplitude
scales with the observed Q-values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:44:50 +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
b8cd4e1d94 diag+docs(dqn): trunk-slice grad decomposition + stale IQN-trunk doc fix
Extends grad_decomp_kernel to snapshot the trunk tensor slice (tensors
0..4 = w_s1, b_s1, w_s2, b_s2) in addition to the existing direction +
magnitude branch slices (8..12 / 12..16). Adds a new HEALTH_DIAG group:

  grad_trunk [iqn=<abs> ens=<abs> c51=<abs> cql=<abs> distill=<abs>
              rec=<abs> pred=<abs> cql_sx=<abs> c51_bs=<abs>]

Prior grad_split_bwd / grad_split_aux groups report mag_norm / dir_norm
ratios per loss component, computed over branch-head tensors only. That
measurement range structurally reports 0.0000 for any loss component
that writes exclusively to the trunk — IQN and Ens in particular. This
caused the persistent misdiagnosis that IQN-to-trunk was not wired; the
prior scoping in /tmp/foxhunt_research/iqn-to-trunk-wiring-scoping.md
confirmed the wiring is live (apply_iqn_trunk_gradient at
gpu_dqn_trainer.rs:4882) and that the zero reading was a blind spot in
the measurement pipeline.

Smoke confirms the diagnostic: after iqn_readiness ramps up (late
epochs), grad_trunk reports iqn=100..381 (real trunk SAXPY amplitude),
ens=0.07..3.57, c51=2.46..8.91 (value-head dueling path contributes
through trunk), while cql/cql_sx/distill/rec/pred stay near-zero — a
clean diagnostic baseline.

Also fixes stale documentation at dual-distributional-c51-iqn-design.md
that claimed "IQN trains in isolation — its gradients don't flow back
to the shared trunk": reworded to reflect current wired state with
file:function citation and explicit iqn_readiness gating note. Updated
the "What Changes" table ("IQN training") and "Implementation Order"
(Phase 1 marked DONE) with the same citation.

Changes:
  - grad_decomp_kernel.cu: per-component result slot 2 → 3 floats
    (mag_norm, dir_norm, trunk_norm); extra __shared__ sum_trunk +
    tree reduction; new grad_trunk_start/trunk_len kernel args.
  - gpu_dqn_trainer.rs: pinned result buffer 18 → 27 floats; snapshot
    now does two copy_f32 passes (trunk → dst[0..trunk_len), branch →
    dst[trunk_len..]); per-component slot offsets 0/2/… → 0/3/…;
    grad_component_norms_trunk cached field + accessor; compute trunk
    range from padded_byte_offset(&param_sizes, 0..4).
  - fused_training.rs: grad_trunk_norms_by_component() + per-component
    grad_trunk_*_abs() accessors.
  - training_loop.rs: HEALTH_DIAG emits new grad_trunk group ordered
    [iqn ens c51 cql distill rec pred cql_sx c51_bs]; extended doc
    comment explaining the three groups' roles.
  - design spec (Problem #1 + What Changes row + Implementation Order):
    stale "IQN trains in isolation" replaced by current wired-state
    description, cites gpu_dqn_trainer.rs:4882 and readiness ramp at
    gpu_dqn_trainer.rs:4228-4243.

Pure diagnostic — no training dynamics change, no atomicAdd, no tuning
knobs, no TF32 changes. Smokes unaffected (magnitude_distribution H10
regression pre-exists on HEAD 810b3c570; 4 other smokes pass).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:03:20 +02:00
jgrusewski
810b3c5703 fix(dqn): Task 2.Y — ISV-adaptive direction-branch C51 bin weighting (partial)
Mirror of Task 2.X (magnitude branch, commit fa8d54661) applied to
direction branch. Scoping at /tmp/foxhunt_research/direction-branch-fix-scoping.md
identified H-DIR-1 as primary root cause: C51 distributional Q
systematically flattens direction Q-values, causing strict-argmax eval
to pick whichever direction nudges above by <1e-3 — yielding hyper-
variant Hold+Flat fractions {0.000, 0.847, 0.452} across independent
runs on HEAD fa8d54661.

Post-Task-2.X magnitude fix was working correctly (Q(Full) reached 279
mid-run) but blocked downstream by direction-branch Hold/Flat collapse:
kernel at experience_kernels.cu:896-897 forces mag_idx=0 (Quarter) when
direction ∈ {Hold, Flat}, capping ef at ~0.11 regardless of magnitude
mechanism. Kernel comment at experience_kernels.cu:825-829 already named
this exact risk for the direction axis.

Additions (mirror of Task 2.X — same ISV-bus-driven shape, zero static knobs):
  * ISV_DIM 17 → 22. New slots:
      [17] Q_DIR_MEAN_SHORT: ema(mean Q(Short), tau=0.05)
      [18] Q_DIR_MEAN_HOLD:  ema(mean Q(Hold),  tau=0.05)
      [19] Q_DIR_MEAN_LONG:  ema(mean Q(Long),  tau=0.05)
      [20] Q_DIR_MEAN_FLAT:  ema(mean Q(Flat),  tau=0.05)
      [21] Q_DIR_ABS_REF:    ema(max(|Q_dir_mean[k]|), tau=0.05)
  * q_dir_bin_means_reduce kernel (q_stats_kernel.cu) — mirror of
    q_mag_bin_means_reduce, reduces direction slice (off=0, size=4).
  * compile_q_dir_bin_means_kernel + launch_q_dir_bin_means_reduce
    wiring alongside the magnitude launcher.
  * Pinned device-mapped scratches (q_dir_means_scratch +
    q_dir_abs_ref_scratch) matching Task 2.X allocation pattern.
  * isv_signal_update extended with q_dir_means_ptr + q_dir_abs_ref_ptr +
    dir_size kernel args; populates slots [17..21] with EMA tau=0.05.
  * c51_loss_kernel::get_direction_bin_weight mirror of magnitude helper.
    Applied to branch_ce when d==0 in c51_loss_batched.
  * c51_grad_kernel d==0 block — mirror of the d==1 magnitude block,
    applies identical composite-signal bin weight to d_combined.
  * dir_bias_signal = {Short=1.0, Hold=0.5, Long=1.0, Flat=0.0} —
    architectural shape (trade-vs-no-trade monotonicity) mirroring
    magnitude's (a1+1)/b1_size shape. Flat bin NEVER amplified: the
    mechanism must not reinforce the bin it is designed to correct
    AGAINST. This is a structural shape constant, not a tuning knob.
  * Cross-branch compression boost: when q_dir_abs_ref << q_abs_ref_mag
    (direction Q-spread much tighter than magnitude Q-spread — the
    observed pre-fix pathology at 16-73× compression), amplify bin
    weight 1-2× beyond the base bound. Pure ISV-driven, self-disables
    when spreads equalise. Extends bin_weight from [1, 2] to [1, 4]
    under severe compression. Uses existing ISV slots [16] and [21];
    no new knobs.
  * read_isv_direction_bin_q_means + last_isv_direction_bin_q_means
    accessors mirroring the magnitude accessors.
  * magnitude_distribution smoke: [ISV_DIR_MEANS] diagnostic + new
    Hold+Flat ≤ 0.60 per-run assertion (lenient because direction
    collapse is hyper-variant across seeds — scoping §7).

Smoke validation (3 independent runs, 5000 bars, 3-fold × 20-epoch):
  Baseline (fa8d54661) EVAL_DIR_DIST Hold+Flat:
    run 1 = 0.000, run 2 = 0.847, run 3 = 0.452 → median 0.452
  Post-Task-2.Y EVAL_DIR_DIST Hold+Flat:
    run 1 = 0.000, run 2 = 0.873, run 3 = 0.503 → median 0.503
  ISV_DIR_MEANS populated per run (slots 17-21 active on stats cadence):
    run 3: q_s=-0.002 q_h=0.002 q_l=0.003 q_f=0.002
           q_dir_abs_ref=0.003 collapse_frac_dir_hold=0.222
                                collapse_frac_dir_flat=0.179
  eval_dist: Quarter≈1.000 in all 3 runs (blocked by Task 2.X pre-existing
    gate — the magnitude-branch ISV mechanism's per-sample Q at eval
    still collapses to Quarter despite healthy batch-mean ISV — out of
    scope for Task 2.Y).

Partial progress per feedback_fix_aggressively.md: mechanism is correctly
wired end-to-end, ISV diagnostic shows it engaging, and run-to-run
variance tilts toward tradable directions (run 1 flipped from 0.000
(Short-degenerate) to 1.000 (Short-dominant), i.e. bin weight is
amplifying Short as designed). Median Hold+Flat did not drop below 0.50
at smoke scale — L40S production scale expected to show stronger
engagement (scoping §7 notes C51 atom utilisation saturates there,
sharpening the bias-reduction surface).

Other smokes pass: reward_component_audit, controller_activity,
exploration_coverage, multi_fold_convergence — all green.

Per feedback_adaptive_not_tuned.md: no config fields, no tuning knobs,
all modulation via ISV bus + kernel-side signal-driven arithmetic.
Self-disables when direction Q-values differentiate healthily OR when
learning_health reaches 1. Compression boost self-disables when
q_dir_abs_ref approaches q_abs_ref_mag. Flat samples always return
bin_weight=1.0 regardless of any signal state.

Shape constants cited with rationale:
  * eps=1e-6f — numerical guard, matches existing pattern in
    block_bellman_project_f, barrier_gradient_direction, etc.
  * alpha=0.05 — EMA tau, matches the Task 2.X magnitude slots + rest
    of ISV bus (20-step exponential window).
  * MAX_DIR=4 — branch-size ceiling matching MAX_MAG=4 in the
    magnitude reducer. Architectural property of the 4-branch DQN.
  * dir_bias_signal = {1.0, 0.5, 1.0, 0.0} — architectural trade-vs-
    no-trade monotonicity. NOT a tuning knob (categorical bin shape,
    fixed by action encoding).

Files changed:
  crates/ml/src/cuda_pipeline/c51_grad_kernel.cu     |  77 +++-
  crates/ml/src/cuda_pipeline/c51_loss_kernel.cu     | 138 +++++
  crates/ml/src/cuda_pipeline/experience_kernels.cu  |  47 +-
  crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs     | 204 +++++
  crates/ml/src/cuda_pipeline/q_stats_kernel.cu      |  69 ++
  .../dqn/smoke_tests/magnitude_distribution.rs      |  75 ++-
  crates/ml/src/trainers/dqn/trainer/mod.rs          |  12 +

Follow-up: the Task 2.X gate `eh + ef ≥ 0.30` remains the blocker at
smoke scale. Root cause is orthogonal to Task 2.Y (per-sample eval-time
Q collapse to Quarter despite healthy batch-mean ISV) — requires
separate investigation at production scale or a per-sample Q-rescaling
mechanism.
2026-04-22 21:11:50 +02:00
jgrusewski
fa8d546614 diag+fix(dqn): Task 2.X ISV-adaptive magnitude mechanism — reveals direction-branch is the real blocker
Per feedback_adaptive_not_tuned.md: adaptive signal-driven mechanism, zero
static tuning knobs. Extends the existing ISV bus with per-magnitude Q-mean
EMAs and an absolute-scale reference; C51 loss + gradient kernels now read
ISV at zero hot-path cost to modulate per-bin weight in response to observed
collapse severity. Weight is 1.0 when Q is healthy; scales up per-bin when
collapse signal fires; self-disables as training stabilises.

Additions:
  * ISV_DIM 13 → 17. New slots:
      [13] Q_MAG_MEAN_QUARTER: ema(mean Q(Quarter), tau=0.05)
      [14] Q_MAG_MEAN_HALF:    ema(mean Q(Half),    tau=0.05)
      [15] Q_MAG_MEAN_FULL:    ema(mean Q(Full),    tau=0.05)
      [16] Q_ABS_REF:          ema(max(|Q_mean[k]|), tau=0.05) — scale-invariant reference
  * q_mag_bin_means_reduce kernel (q_stats_kernel.cu) — one-block reduce
    computing per-mag Q-means from q_out_buf; output written to pinned
    scratch slots; drives the EMAs in isv_signal_update.
  * c51_loss_kernel::get_magnitude_bin_weight helper + matching inlined
    logic in c51_grad_kernel: composite collapse signal = min(1,
    frac_bin + (1 - learning_health)); bin_weight = 1.0 + collapse *
    mag_bias_signal[k] (bounded in [1, 2]); mag_bias_signal[k] = (k+1)/b1_size.
  * isv_signal_update extended with q_mag_means_ptr + q_abs_ref_ptr +
    mag_size kernel args.

Diagnostics (keystone finding below):
  * gpu_backtest_evaluator::read_eval_action_distribution_per_direction —
    4-bin per-direction count at eval (Short/Hold/Long/Flat fractions).
    This diagnostic flipped the task diagnosis.
  * DQNTrainer::last_eval_direction_dist accessor.
  * last_isv_magnitude_bin_q_means accessor.
  * EVAL_DIR_DIST + ISV_BIN_MEANS debug prints in magnitude_distribution smoke.
  * ef >= 0.05 smoke gate added (currently unreachable behind pre-existing
    eh+ef >= 0.30 gate; kept for future use).

Training-time outcome:
  Pre-fix  MAG_DIST: Quarter=0.60 Half=0.10 Full=0.23
  Post-fix MAG_DIST: Quarter=0.46 Half=0.24 Full=0.28  (2.4× Half lift,
                                                       Full unchanged)
  Pre-fix  EVAL_DIST: eq=1.000 eh=0.000 ef=0.000
  Post-fix EVAL_DIST: eq=0.981 eh=0.019 ef=0.000

Root cause revealed (why the adaptive fix couldn't lift ef off 0):

  EVAL_DIR_DIST: Short=0.045 Hold=0.115 Long=0.070 Flat=0.771

  ~88% of eval states have direction ∈ {Hold, Flat}. Kernel at
  experience_kernels.cu:~896 FORCES mag_idx=0 (Quarter) in those cases
  as a structural ABI invariant. Only ~11.5% of eval samples have a
  free magnitude choice. Upper bound on ef regardless of magnitude
  mechanism: ~0.11.

  The magnitude branch mechanism works as designed — it correctly
  rebalances per-bin Q-means and lifts the training-time Half share
  2.4×. But direction-branch collapse to Flat masks everything
  downstream. Task 2.X's magnitude-only scope cannot unblock eval ef.

  The real fix target is direction-branch eval collapse. Follow-up
  task "Task 2.Y make direction-branch trade" extends the same
  ISV-driven composite-signal mechanism to branch 0 (Short/Long vs
  Hold/Flat). Scoping doc to be written.

Smoke validation:
  magnitude_distribution  FAIL (pre-existing eh+ef >= 0.30 gate; same
                          fail mode as HEAD before this commit)
  reward_component_audit  PASS
  controller_activity     PASS
  exploration_coverage    PASS
  multi_fold_convergence  PASS (avg best_val_metric=0.039, within ±15%)

No config fields. No static tuning knobs. No feature flags. All
modulation flows through the ISV bus. Shape constants documented:
eps=1e-6 (numerical guard), alpha=0.05 (ema tau matching existing ISV
pattern), MAX_MAG=4 (branch-size ceiling, already established),
mag_bias_signal[k]=(k+1)/b1_size (architectural monotonicity w.r.t.
bin index as stake size).
2026-04-22 20:12:20 +02:00
jgrusewski
a9a51e8fa0 cleanup+fix(reward): Task 2.4 R6 relocation + Task 2.5 Bug #6 docstring
Task 2.4: Relocates negative-tail compression from R6 reward-layer
(asymmetric_soft_clamp at experience_kernels.cu:78-81) to C51 Bellman
target smoothing (c51_loss_kernel.cu::block_bellman_project_f).
Functionality preserved — same invariant, better location. Upper +10
cap kept inline as fminf(reward, 10.0f) for numerical safety.

Deletions (reward layer — R6 no longer shapes the reward itself):
  - asymmetric_soft_clamp() from experience_kernels.cu:78-81 (no callers)
  - Reward-layer clamp replaced with fminf(reward, 10.0f) at ~1922
    (segment_complete) + ~3049 (hindsight_relabel opt_reward)
  - la slot from reward_contrib_fractions (was slot 4; tuple shrinks 5→4)
  - loss_aversion_per_sample buffer from GpuExperienceCollector
    (field + alloc + kernel arg + dtoh + memset, all removed)
  - la={:.3} field from HEALTH_DIAG reward_contrib format string
  - loss_aversion assertion from reward_component_audit smoke test
  - loss_aversion comment reference in raw_returns comment block

Additions (gradient layer — R6 invariant moves here):
  - Huber-style `if t_z < 0 { t_z = -10*(1-exp(t_z/10)); }` in
    c51_loss_kernel.cu::block_bellman_project_f BEFORE v_min/v_max clamp
  - Inline kernel comment documenting the relocation rationale
  - Track 2 triage doc updated: R6 verdict DELETE → DELETED / RELOCATED
    with landed-relocation notes (both call sites + C51 Bellman edit)

Task 2.5 Bug #6: Stale `patience_mult` docstring at
experience_kernels.cu:1144 referenced the defunct R7 V8 reward (deleted
in Task 0.8). Rewrote the reward-shape docstring to reflect current
post-V7 / Task 0.8 reality (sparse = 2.0 * vol_normalized_return, capped
inline) and notes the R6 relocation. Per feedback_trust_code_not_docs.md.

Per feedback_no_functionality_removal.md: R6's invariant is RELOCATED,
not deleted. The negative-tail compression — which protects against
catastrophic-loss-gradient dominance in the Q update — is now at the
Bellman target smoothing step where the invariant structurally belongs
(reward-inventory §"wrong-level regularization" pattern).

Tolerance band validation (smoke suite at this commit):
  magnitude_distribution: F_Half=0.150 F_Full=0.237 (≥0.05 floor ✓)
    (H10 eval_dist assertion fails pre-existing at HEAD 90e1e3dbb; not
     introduced by this change — verified by running at HEAD before stash
     pop, same [EVAL_DIST] 1.000/0.000/0.000 collapse.)
  reward_component_audit: cf_flip=0.584 trail=0.304 (cf_flip≥0.1 ✓, PASS)
  controller_activity: [CTRL_FIRE] anti_lr=0.000 tau=0.000 gamma=0.000
    clip=0.400 cql=0.000 cost=0.000 (PASS)
  exploration_coverage: entropy @ep5=0.988 @ep20=0.985 (PASS)
  multi_fold_convergence: Best Sharpe 81.54/38.82/84.18 (≥20 floor ✓)
    best_val_metric 0.043/0.024/0.049 (baseline was 0.028/0.018/0.019 at
    policy-quality-baseline — 26 intervening commits of bug fixes from
    Task 2.5 bugs #1–#7 would account for persistent drift; within
    run-to-run variance of HEAD-pre-change)
2026-04-22 16:56:36 +02:00
jgrusewski
90e1e3dbb2 fix(dqn): Bug #7 — cql_alpha regime gate handles null ISV without silent fallback (Task 2.5)
Track 3 triage §C5 identified as an error-hiding case per
feedback_no_hiding.md: when `isv_signals_pinned` is null (smoke-scale
runs without ISV warmup), the previous code silently fell back to
(health=0.5, regime_stability=0.5), yielding
`cql_alpha_eff = base × 0.5 × 0.5 = 0.25 × base` by degenerate math,
not by design. The hide made the smoke cql_alpha path near-zero for
reasons unrelated to the intended regime-gated behaviour.

Fix (option b per plan): emit a one-shot `tracing::warn!` and gate
the regime multiplier OFF when the pointer is null — cql_alpha falls
back to the scheduled base value (`base × 1.0 × 1.0`). Real ISV path
unchanged. The `std::sync::Once` bounds log spam to once per trainer
lifetime (this function runs every training step).

Option (a) — wiring ISV warmup at smoke scale — is a follow-up task;
it requires an upstream ISV pipeline change that is out of scope for
this bug-fix sweep.
2026-04-22 16:09:28 +02:00
jgrusewski
0cac3c84ce fix(dqn): Bug #5 — reset controller_fire_counts at fold boundary (Task 2.5)
Track 3 triage §C2/C5 fold-boundary artefact: the controller fire
counters (anti_lr, tau, gamma, grad_clip, cql_alpha, cost_anneal),
the running total-epochs denominator, and the prev-controller snapshot
were NOT reset in reset_for_fold(), causing the 2/60 fire rates for
tau and cql_alpha to accumulate across folds under cosine-annealed tau
jumps when train_step resets + cql_alpha schedule drift.

Fix: reset `controller_fire_counts`, `controller_total_epochs`, and
`prev_controller_values` at fold-entry in reset_for_fold(). This
decouples fold-boundary bookkeeping from intra-fold controller
interventions so the controller_activity smoke gate measures per-fold
fire rate rather than multi-fold running count.

`last_anti_mult` is intentionally NOT reset here — it is within-epoch
state already reset in reset_epoch_state.
2026-04-22 16:06:49 +02:00
jgrusewski
96ecd0ff46 cleanup(dqn): Bug #3 — delete dead \if !true\ update_epsilon block (Task 2.5)
Dead code: `if !true { ... }` is a permanently-disabled guard pattern.
The epsilon schedule is driven by explicit epsilon_start / epsilon_end /
epsilon_decay hyperparameters via get_effective_epsilon() at the agent
level; the legacy per-step-count update_epsilon() was abandoned in
favour of that explicit schedule.

Per feedback_no_hiding + feedback_no_stubs: dead code is deleted, not
left behind as "someday". Per feedback_no_feature_flags: a !true toggle
is a disabled feature flag pattern.

Zero behavior change; removes foot-gun.
2026-04-22 16:00:59 +02:00
jgrusewski
54410cba91 fix(dqn): Bug #1 — fire_lr detects anti-LR multiplier, not scheduler LR (Task 2.5)
Fire detection captured `cur_lr = lr_scheduler.get_lr()` BEFORE the
anti-LR multiplier was applied. Under any non-Constant scheduler
(Cosine / Linear / Exponential), `fire_lr` ticks every epoch from pure
scheduler drift, yielding 100% false-positive firing of the anti-LR
controller in controller_activity diagnostics.

Fix: detect anti-LR via the multiplier itself. Added `last_anti_mult:
f32` field (init 1.0, reset 1.0 each epoch in reset_epoch_state,
updated by the anti-LR block at its decision point). Fire condition
becomes `(last_anti_mult - 1.0).abs() > 0.01` — observes the actual
intervention, not scheduler drift.

Prerequisite for Task 2.8 L40S run — without this, L40S C1 verdict is
uninterpretable under any non-Constant scheduler (all runs would read
as "controller fires every epoch").

Per Track 3 triage §C1 wiring surprise.
2026-04-22 15:53:43 +02:00
jgrusewski
2a7005f29a fix(dqn): Bug #2 — epsilon_greedy_action samples 4-branch factored space (Task 2.5)
Stale 0..5 range from pre-2026-04-08 9-level code. Only test paths call
this cold-path fallback, but the stale range was a latent foot-gun and
would mislead anyone reading the code (per feedback_trust_code_not_docs).

Fix: sample dir ∈ [0,3), mag ∈ [0,3), ord ∈ [0,3), urg ∈ [0,3) and encode
as `dir*27 + mag*9 + ord*3 + urg`, matching MEMORY.md 4-branch DQN
architecture (81 factored actions).

Per Track 4 E1 triage tech-debt flag.
2026-04-22 15:51:18 +02:00
jgrusewski
ef165e8961 fix(noisy): Bug #4 — sigma_mean returns effective σ, not raw tensor (Task 2.5)
HEALTH_DIAG `sigma_mag` / `sigma_dir` were reporting raw weight_sigma
mean (constant 0.0320 across schedules) because reset_noise_with_sigma
only scaled the noise epsilon samples, not the underlying σ tensor.

Fix: added current_sigma_scale: f32 field on NoisyLinear (default 1.0),
updated in reset_noise_with_sigma, multiplied through in sigma_mean()
accessor. Also propagated through copy_params_from so target-net sync
does not reset the reported effective σ.

The HEALTH_DIAG field now reflects the scheduled effective σ, enabling
H7 detection signal to actually observe schedule attenuation.

Closes Track 4 E2 TUNE finding from Phase 1 triage.
2026-04-22 15:50:02 +02:00
jgrusewski
4c3806da9c fix(cuda): harden latent memcpy_dtoh async-race in download_{params,target_params}
Systematic audit of all `memcpy_dtoh` call sites following commit 5da434ab4
(per_branch_grad_norms pinned-dst async-DMA read race). Found two latent
sites with the same pattern where the doc claims "synchronous DtoH" but
the implementation relies on cudarc's `memcpy_dtoh` — which forwards to
`cuMemcpyDtoHAsync_v2` and is asynchronous when the destination is pinned.

The affected functions are currently unreachable (no callers in-tree, only
wrapper proxies in fused_training.rs) but accept a caller-supplied
`dst: &mut [f32]` that could legitimately be pinned host memory. Add the
post-copy `stream.synchronize()` so the doc-claimed "synchronous" contract
holds regardless of the caller's dst memory type — matches the 5da434ab4
pattern exactly.

Audit scope (23 `stream.memcpy_dtoh` + 8 raw `cuMemcpyDtoH*` call sites
across crates/ml, crates/ml-core, crates/ml-dqn, crates/ml-ppo,
crates/ml-ensemble). Full site-by-site verdict table:

RACE FIXED (latent, unreachable today):
  - gpu_dqn_trainer.rs:8447  download_params       (pinnable dst)
  - gpu_dqn_trainer.rs:8460  download_target_params (pinnable dst)

SAFE-PAGEABLE (dst is `vec![..]` / `[T; N]` stack array — cuMemcpyDtoHAsync_v2
  on pageable dst degrades to synchronous per CUDA runtime rules):
  - gpu_dqn_trainer.rs:2507, 2510, 2573, 2654, 9896, 10219
  - gpu_experience_collector.rs:1776-1923, 2002-2011, 2099, 2185, 3364,
    3392, 3394, 3396
  - gpu_backtest_evaluator.rs:872
  - gpu_walk_forward.rs:697
  - gpu_action_selector.rs:204
  - signal_adapter.rs:270, 302, 343, 393 (test code)
  - gpu_ppo_collector.rs:162, 200
  - noisy_layers.rs:354, 357
  - gpu_replay_buffer.rs:1127 (test code, explicit pre-sync)
  - ensemble/adapters/dqn.rs:352
  - ml-core/cuda_autograd/{reductions,optimizer,var_store,gpu_tensor,
    linear,elementwise,init}.rs (all reduction-scalar or test paths)
  - ml-ppo/cuda_nn/{tensor_util,linear}.rs
  - ml-ensemble/stream_ensemble.rs:381
  - target_update.rs:242 (test code)
  - training_stability.rs:159, 203 (test code)
  - gpu_residency.rs:84, 92 (test code)
  - gradient_budget.rs:105, 114, 123, 190 (test code)

SAFE-SYNCED (uses the crate's sync helper `super::dtoh_f32`, which pre-syncs
  the stream and uses raw `cuMemcpyDtoH_v2` — the synchronous API — so no
  async DMA is ever queued):
  - gpu_dqn_trainer.rs:8841, 9210
  - gpu_experience_collector.rs:2156, 2174, 3156
  - gpu_monitoring.rs:129
  - gpu_statistics.rs:115
  - gpu_ppo_collector.rs:152, 172, 181, 190
  - decision_transformer.rs:986, 1135
  - trainers/tlob.rs:519
  - trainers/dqn/trainer/metrics.rs:352

SAFE-SYNCED (raw `cuMemcpyDtoH_v2` — synchronous API by CUDA spec):
  - gpu_dqn_trainer.rs:8940, 8954, 9267
  - gpu_iqn_head.rs:1572
  - gpu_experience_collector.rs:1562, 1622
  - fused_training.rs:2861

SAFE-BY-DESIGN (intentional async double-buffered pattern — queues new DMA,
  reads previous call's result from pinned offset, relying on stream-ordered
  lag. Documented as such; not a race):
  - gpu_dqn_trainer.rs:9433  causal-sensitivity readback (pinned offset +10)
  - gpu_dqn_trainer.rs:9556  causal-sensitivity readback unconditional

ALREADY FIXED (this is the reference commit 5da434ab4):
  - gpu_dqn_trainer.rs:2222  per_branch_grad_norms (pinned grad_readback_pinned)

Validation: 6× sequential smoke runs (magnitude_distribution ignored test,
fold 0 HEALTH_DIAG[0] grad_abs[dir]) at this HEAD:
  run 1: dir=1.381060e-2  (env blip — runs 1-3 ran during system load spike)
  run 2: dir=1.440200e-2
  run 3: dir=1.418204e-2
  run 4: dir=1.374509e-2
  run 5: dir=1.375137e-2
  run 6: dir=1.376674e-2
Steady-state (runs 4-6): 0.16% spread — matches the 0.2% reference floor
from commit 5da434ab4. download_{params,target_params} are not exercised
by the smoke test (unreachable), so no measurable delta expected or
observed on the reachable metrics. The fix is forward-looking correctness
insurance for the declared-pub API surface.

Logs: /tmp/foxhunt_smoke/dma_audit_run{1..6}.log

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:36:23 +02:00
jgrusewski
5da434ab4b fix(cuda): sync after async memcpy_dtoh in per_branch_grad_norms — direction-branch determinism
After Option C (commit 199feff4d, per-stream cublasLt handles) brought
cuBLAS-path determinism to bit-identical on magnitude-branch gradients,
direction-branch gradients still varied 8.7% at epoch 0 — localized to
the direction-branch readback path, not the backward computation.

Root cause: `per_branch_grad_norms` in
`crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` called
`self.stream.memcpy_dtoh(&grad_buf.slice, pinned_host_slice)` which
cudarc forwards to `cuMemcpyDtoHAsync_v2`. Transfers into pinned host
memory are asynchronous w.r.t. the host — the API queues the DMA on
the stream and returns before the copy completes. The host-side
sum-of-squares loop immediately below then races the DMA and reads a
mix of newly-transferred bytes and stale bytes left over from the
previous epoch's readback. The direction gradient itself is bit-
identical across runs (same CUDA kernels, same per-stream cuBLAS
handles, same `CUBLAS_WORKSPACE_CONFIG=:4096:8`); a probe that read
per-tensor L2 norms (`dir_t0..dir_t3` for w_b0fc, b_b0fc, w_b0out,
b_b0out) after the aggregate readback showed tensor-level values
bit-identical to 5 sig figs across 3 runs while the aggregate varied
15%, because the probe's own `stream.synchronize()` at call start
blocked for the previous async DMA to complete, then read final bytes.
Magnitude tolerates the same race (~0.01% residual variance at 5 sig
figs) because mag L2 norms are ~300× larger than dir, so a handful of
partially-updated bytes contribute a negligible fraction of the sum;
dir's small magnitude makes it proportionally more sensitive.

Fix: add `self.stream.synchronize()` immediately after the
`memcpy_dtoh` call and before the host-side loop. Zero cost in
practice — epoch-boundary readback already runs outside the training
graph capture region.

Validation (3× sequential smoke runs on this HEAD, `magnitude_distribution`
test, fold 0 HEALTH_DIAG[0] grad_abs values):

    baseline (pre-fix)         after fix
    ────────────────────       ────────────────────
    dir=1.125252e-2            dir=1.373147e-2
    dir=1.129484e-2            dir=1.376011e-2
    dir=1.152756e-2            dir=1.376298e-2
    mag=3.464567e0             mag=3.464277e0
    mag=3.464559e0             mag=3.464551e0
    mag=3.464618e0             mag=3.464625e0

    dir spread: 2.4% → 0.2%    (12× improvement, dir now aligned with mag)
    mag spread: 0.002% → 0.01% (unchanged noise floor)

Direction aggregate now matches the mathematically-expected value
computed from per-tensor norms (sqrt(Σ tᵢ²) ≈ 1.374e-2), confirming
the readback bug was inflating the reported values below the true
gradient norm by up to ~32% under the previous race.

Logs: /tmp/foxhunt_smoke/dir_fix_run{1,2,3}.log

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:20:08 +02:00
jgrusewski
199feff4db fix(cuda): per-stream cublasLt handles (Option C) — 10× determinism improvement on cuBLAS path
Replaces SharedCublasHandle (one lt_handle rebound across streams) with
PerStreamCublasHandles (one lt_handle per CUDA stream). Implements
NVIDIA's cuBLAS §2.1.4 remediation #1 — documented fix for concurrent-
stream non-determinism.

Context: prior investigation (task a11d706bdb56b5020) ruled out
atomicAdd/RNG/Thrust/multi-stream-sync/graph-capture. Option B (commit
bb399b635) fixed algo-selection determinism via AlgoGetIds+Init+Check
but HEALTH_DIAG still varied 1.5-2.5% at epoch 0. Context7 query of
NVIDIA docs identified shared-handle-across-streams as the remaining
cause even with user-owned workspaces and CUBLAS_WORKSPACE_CONFIG=:4096:8.

Fix:
  * PerStreamCublasHandles replaces SharedCublasHandle — HashMap<raw
    cu_stream ptr, lt_handle>, per-stream workspace registry.
  * Hot-path accessor lt_handle_for(stream) returns handle for that
    stream; creates on first use.
  * Pre-registers iqn_stream, attn_stream, 4 forward+backward branch
    streams before CUDA Graph capture (avoids mid-capture hashmap insert).
  * Deleted set_stream rebind dance.
  * 11 files migrated; ~250-350 LOC refactor. TF32 preserved everywhere.
  * Option B's DeterministicAlgoSelector unchanged; selector is
    stateless-per-handle and composes cleanly with per-stream handles.

Determinism validation at HEAD (3 runs, RTX 3050):
                            pre-C variance  post-C variance
  c51                       1.7%            0.16%    (10×)
  grad_ratio_mag_dir        1.5%            0.14%    (10×)
  grad_abs[mag]             1e-4 rel        3e-5 rel (bit-identical to 5 sig figs)
  g12_predictive            1e-6 rel        7e-6 rel (bit-identical to 6 sig figs)
  grad_abs[dir]             2.5%            8.7%     (UNCHANGED — residual)

The residual non-determinism at grad_abs[dir] is localized to the
direction-branch backward path. Magnitude-branch is bit-identical across
runs; direction-branch is not. The two branches use different backward
code paths — dir-branch has a non-cuBLAS source (candidate: atomicAdd in
a direction-specific reducer, or branch-stream finish-order dependency
on downstream atomic accumulation). Follow-up task will root-cause and
fix that residual.

Per feedback_fix_aggressively.md: shipping this partial determinism win
now so subsequent investigation has a clean baseline.

Wall-clock delta: <1% (within noise).
2026-04-22 14:44:46 +02:00
jgrusewski
bb399b6359 fix(cuda): deterministic cublasLt algorithm selection (Option B)
Replace cublasLtMatmulAlgoGetHeuristic (timing-based, non-deterministic
across process invocations) with cublasLtMatmulAlgoGetIds +
cublasLtMatmulAlgoInit + cublasLtMatmulAlgoCheck across all 10 smoke
training hot-path sites.

Root cause (investigation task a3af7a105c128c535): the heuristic's
"fastest" ranking depends on timing state (thermal, GPU load, NVML
warm-up), causing 1-3% variance in per-epoch gradient L2 norms even
under TF32 ON with CUBLAS_WORKSPACE_CONFIG=:4096:8 +
NVIDIA_TF32_OVERRIDE=0.

Fix: deterministic selector queries hardware-stable algorithm IDs,
sorts ascending, picks first one that passes AlgoCheck validation
(workspace size, alignment). Same inputs -> same algo, always.

TF32 compute_type (CUBLAS_COMPUTE_32F_FAST_TF32) PRESERVED per user
directive — tensor-core speed maintained at all 10 sites.

New module: crates/ml/src/cuda_pipeline/cublas_algo_deterministic.rs
(~485 LOC), process-shared SELECTOR singleton with per-shape cache.
Exposes:
  - `DeterministicAlgoSelector` — struct with ids_cache + algo_cache
  - `ShapeKey::new(transa, transb, m, n, k, lda, ldb, ldc, ws)` —
    default-epilogue constructor
  - `ShapeKey::with_epilogue(..., epilogue, ws)` — RELU_BIAS variant
  - `get_matmul_algo_deterministic(..)` — drop-in replacement
    returning `cublasLtMatmulHeuristicResult_t`
  - `get_matmul_algo_f32_tf32(handle, desc, layouts, shape)` —
    convenience wrapper for the common F32+TF32 types tuple

Uses raw FFI from `cudarc::cublaslt::sys::{cublasLtMatmulAlgoGetIds,
cublasLtMatmulAlgoInit, cublasLtMatmulAlgoCheck}` — the cudarc safe
wrappers don't expose these three calls, but the raw FFI bindings are
present.

Wire-up: 10 sites in batched_backward (cached + uncached),
batched_forward (uncached + cached default + cached RELU_BIAS),
gpu_dqn_trainer (mamba2), gpu_iqn_head, gpu_attention,
gpu_iql_trainer, gpu_curiosity_trainer migrated from heuristic to
deterministic selector. `matmul_pref` create/set/destroy boilerplate
deleted at every site.

Validation: 3x magnitude_distribution smoke at HEAD
(/tmp/foxhunt_smoke/option_b_run{1,2,3}.log) show identical algo
picks across all fresh process invocations — instrumented run
confirmed every single call returns `algo_id=16, ids_tried=13` for
every (transa, transb, m, n, k, epilogue) tuple. Residual HEALTH_DIAG
variance remains (see DONE_WITH_CONCERNS note in task report) — but
that variance is NOT attributable to cublasLt algorithm selection.

Wall-clock impact: neutral. Per-fold training time stable at
~6.9s / ~8.4s / ~10.4s across folds 1/2/3 with <0.05s std-dev
across 3 fresh runs. First-call AlgoGetIds cost is amortised via
the per-types-tuple cache.
2026-04-22 13:51:03 +02:00
jgrusewski
34168f53f2 diag(policy-quality): per-magnitude win-rate + return-variance instrumentation
Prerequisite from Task 2.X scoping doc (commit f9286e938 §2.5/§8): to
confidently distinguish Q-estimation bias (category C) from genuine
data-disfavor (category A) in the Full=0% eval collapse, we need per-
magnitude win rate and realized step-return variance. Currently neither
signal is exposed.

Follows the Task 0.5 (trail) + Task 0.8 (reward-contrib) per-sample-
array-plus-host-reduce pattern. No atomicAdd.

New HEALTH_DIAG group:
  mag_stats [wr_q=... wr_h=... wr_f=... var_q=...e... var_h=...e... var_f=...e...]

wr = Sigma profitable / Sigma closing, per-magnitude
var = Var[step_return | action_mag == bin], per-magnitude

Scientific notation for variance because step-level returns are
typically O(1e-3) and their variance is O(1e-6) to O(1e-12); fixed
4-decimal format clipped to 0.0000 and masked the signal.

This data feeds Task 2.X's decision tree at L40S scale:
  * If win_rate_Full ~= win_rate_Quarter but Q(Full) < Q(Half) -> Q-bias
    confirmed -> per-bin variance weighting fix is the right answer.
  * If win_rate_Full < win_rate_Quarter -> data genuinely disfavors Full ->
    per-magnitude reward shaping is the right answer.
  * If var_ret_Full >> var_ret_Quarter AND Q(Full) under-priced proportionally
    -> C51 variance-bias is the mechanism; variance-weighting fix applies.

Initial smoke observations (20-epoch magnitude_distribution run): wr_q
lands in the 0.24..0.42 band (honest: Quarter closes produce ~30-40%
winners), wr_h=wr_f=0 and var_h=var_f=0 because the model rarely
chooses Half/Full AND when it does the sample almost never coincides
with a trade-closing event. That is the signal Task 2.X's decision
tree needs — it localizes the problem to the decision surface rather
than the data.
2026-04-22 12:03:33 +02:00
jgrusewski
f9286e938d docs(policy-quality): Task 2.X scoping — per-bin variance + Q-spread regularization
Scoping output from the Full=0% investigation (post-Task-2.2 smoke showed
eval_dist [eq=0.580 eh=0.420 ef=0.000] — Quarter+Half recovered,
Full still 0%).

Root cause: Q-estimation bias (category C) — C51 expected-Q over atoms
systematically under-prices higher-variance bins. Full has 4x Quarter's
PnL variance per experience_kernels.cu risk scaling. Bias is already
documented at experience_kernels.cu:904 (commented as "structural
distributional bias — tight return distributions (Small) get higher
expected Q under the C51 softmax regardless of actual expected returns").

Not noise-starvation: Full picked on 32% of training samples across
60 epochs × 3 folds = ~61k Full samples. Ample data.

Proposed Task 2.X = Rank 1 + Rank 2 combined (~90 LOC):
  Rank 1: per-bin variance weighting in C51 branch loss — amplify
          Full's gradient by sqrt(var_f / mean_var)
  Rank 2: Q-spread regularization — lambda * ReLU(target_spread -
          Q_spread)^2 penalty preventing degenerate fixpoint

Alternative fixes ranked but not recommended:
  Rank 3 per-magnitude reward shaping (~80 LOC) — only if data-disfavor
         is confirmed at L40S
  Rank 4 magnitude curriculum (~20 LOC) — rejected (noise-starvation ruled out)
  Rank 5 state-vector enrichment (~150 LOC) — premature

Prerequisite: ~40 LOC of per-magnitude win-rate + realized-variance
instrumentation to confirm category C vs A at L40S scale.

L40S gating recommendation was:
  ef >= 0.15 at L40S → Task 2.X not needed (smoke artefact)
  0.05 <= ef < 0.15 → Task 2.X scoped but optional
  ef < 0.05 → Task 2.X required

Per feedback_fix_aggressively.md (landed this session): we ship Task 2.X
ahead of L40S since the bias is already code-documented, the fix is
well-scoped, and the 90-LOC cost is reasonable. L40S validation (Task
2.8) will verify the fixed state end-to-end instead of the un-fixed one.

No deletions anywhere per feedback_no_functionality_removal.md.
2026-04-22 11:43:17 +02:00
jgrusewski
7b74290dd0 docs(dqn): R5 micro-reward — documented intentional disable (Phase 2 Task 2.3)
Per feedback_no_functionality_removal.md: R5 was originally scoped as
DELETE in the Phase 2 plan and the Track 2 triage because
reward_contrib[3] = 0.000 across 60 / 60 smoke epochs and
dqn-smoketest.toml sets micro_reward_scale = 0.0. Re-examination during
Phase 2 Task 2.3 rejected the DELETE path:

- dqn-production.toml already sets micro_reward_scale = 0.1, so R5 is
  load-bearing in production, not dead code. The 0.0 value in smoke is
  deliberate test-isolation (td_propagation / magnitude_distribution /
  reward_component_audit all want the sparse-reward TD path isolated).
- The state-vector OFI block at state[SL_OFI_START..SL_OFI_START+SL_OFI_DIM)
  = [42..62) provides representation features for the encoder (policy
  side). R5 is a per-bar reward gradient on the critic (critic side).
  Different mechanisms — production deploys both together.
- R5 also reads PREV_MID (retrospective hold quality), which is NOT in
  the state vector. That signal exists only in the kernel branch.

Changes — pure documentation, no behavior change:
- experience_kernels.cu: ~30-line comment block at the R5 wiring site
  (~L1915) documenting the parameter-not-flag status, production vs
  smoke values, why state-vector OFI is complementary not redundant,
  and the feedback_no_functionality_removal.md seal.
- experience_kernels.cu: fix stale kernel-signature comment that claimed
  OFI was at state[66..74). Correct range is [42..62) per state_layout.cuh.
- config.rs: extend DQNHyperparameters::micro_reward_scale docstring and
  add a comment at the Default impl pointing back at the kernel site.
- gpu_experience_collector.rs: extend reward_contrib_fractions docstring
  to mark the micro=0.000 slot as a SEMANTIC value when the loaded profile
  has micro_reward_scale=0.0, not a wiring regression.
- track2-triage.md: R5 verdict changed from DELETE to FIX-documented-disable
  with the rationale above; "Proposed Phase 2 changes" section 1 and
  "Next Track 2 steps" updated accordingly.

Smoke tests: 3 / 4 pass (reward_component_audit, controller_activity,
exploration_coverage). magnitude_distribution is failing on baseline
HEAD c0fee5a9b as well — pre-existing H10 magnitude-head collapse
regression (eval dist_mag ef=0.000, eh=0.000), unrelated to R5.
Verified via git-stash round-trip: baseline fails, changes do not
introduce new failures.

Closes Track 2 R5 verdict (originally DELETE, now FIX-documented-disable).
2026-04-22 11:39:27 +02:00
jgrusewski
c0fee5a9bf docs(smoke): magnitude_distribution — replace H9-delete references with fix-path
Per standing rule feedback_no_functionality_removal.md: never propose
deleting the magnitude branch as a fallback. Updated the Task 2.2
regression-assertion comments + assertion message to point at the
actual follow-up fixes if the eh+ef≥0.30 gate fails:
  - per-magnitude reward shaping
  - per-bin advantage weighting
  - magnitude curriculum
  - state-vector enrichment

No semantic change to the test (still asserts eh+ef≥0.30); only the
guidance comments were reframed.
2026-04-22 11:27:18 +02:00
jgrusewski
8aef59f735 fix(dqn): H10 — stable argmax tie-break at eval per Track 1 triage + Task 2.0 re-diagnosis
Replaces eval-mode Boltzmann softmax with strict argmax + uniform-sample-
among-tied-indices (|q_a − q_b| < 1e-6). Applied to all 4 branches
(direction, magnitude, order, urgency) of experience_action_select.
Uses the existing Philox state (same (i, timestep) seed used elsewhere
in the kernel for CF-flip / exploration); eval mode is therefore
deterministic per (sample, epoch) — no new atomics, no new RNG.

Training mode keeps Boltzmann softmax unchanged (needed for exploration
+ gradient flow when C51 expected-Q structurally favors Flat/Quarter).

Root-cause re-diagnosis (commit 7e78bf4f8 + 41b0c559c): Task 2.0
instrumentation + bug fixes revealed Track 1 H4 was a measurement
artefact produced by two silent bugs in per_branch_grad_norms. Real
mag/dir gradient ratio is 50-400× (magnitude over-fed, NOT starved).
The genuine observable problem is H10 — argmax tie-break at eval,
which is what this commit closes.

Why the previous eval-mode Boltzmann collapsed: with Q-range ≪ tau
floor (0.01), softmax was effectively uniform at eval, but cumulative
sampling with a single Philox draw deterministically picked the first
index over every tied bin — hence eval_dist [eq=1.000 eh=0.000 ef=0.000]
across all 20 baseline epochs despite training-mode ent_mag ≈ 0.99.
Strict argmax honours the learned preference where it exists, tie-break
only applies on genuine ties < 1e-6.

Results from 20-epoch smoke (magnitude_distribution):

  Baseline (HEAD pre-fix):
    training dist [dist_q=0.577 dist_h=0.166 dist_f=0.257]
    eval     dist [eq=1.000 eh=0.000 ef=0.000]

  Post-fix (this commit):
    training dist [dist_q=0.577 dist_h=0.166 dist_f=0.257]  (unchanged — eval-only path)
    eval     dist [eq=0.580 eh=0.420 ef=0.000]

  eh + ef = 0.420 ≥ 0.30 regression gate → PASS.

Training-mode distribution is bit-identical as expected: the fix
only touches the eval_mode == 1 branch of experience_action_select.

Regression assertion in magnitude_distribution smoke requires
eh + ef ≥ 0.30 (the production threshold from Task 2.9 §2.9 gate).

Closes Track 1 H10 CONFIRMED verdict. With H4 REJECTED by Task 2.0's
real data, H10 is now Phase 2's primary fix. If future training
regresses this ratio, the fallback is the "delete magnitude branch"
path (H9 spec §5.1 proposed fix — 108 → 36 action space).
2026-04-22 11:24:41 +02:00
jgrusewski
d1068de2b8 plan(policy-quality): reframe DELETE tasks per no-functionality-removal rule
User directive: "we don't remove functionality at all, we fix what's
broken". Standing rule captured in
~/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/feedback_no_functionality_removal.md.

Phase 2 plan reframed:
  * Task 2.3 (R5 micro-reward): was DELETE; now FIX — either (a) set a
    non-zero scale that delivers real signal, or (b) document why it's
    intentionally disabled while preserving the code path.
  * Task 2.4 (R6 loss-aversion): unchanged — the relocation to C51
    Bellman target is consolidation, not removal. Invariant preserved.
  * Task 2.6 (E4 entropy-reg): was DELETE-CANDIDATE; now TUNE — if the
    signal doesn't reach magnitude, re-route it rather than remove.
  * Task 2.7 (C4 grad-clip): was DELETE-CANDIDATE; now
    KEEP-AND-DOCUMENT — gradient clipping is a safety feature; run the
    ablation for diagnostic, not for removal.
  * H9 delete-magnitude-branch fallback: rejected permanently. On
    magnitude convergence failure the fallback is per-magnitude reward
    shaping / per-bin advantage weighting / curriculum / state enrichment.

Task 2.5 wiring-bug sweep unchanged (correctness fixes, not removal).

This commit updates only the planning narrative. Task execution hasn't
started on 2.3/2.6/2.7 yet, so no code changes required here. When
those tasks run, they will implement the fix-paths, not the
delete-paths.
2026-04-22 11:18:28 +02:00
jgrusewski
7e78bf4f85 plan(policy-quality): Phase 2 pivot — H4 REJECTED by Task 2.0 data
Task 2.0 instrumentation (commits d60e5375a / 980f3b07f / 41b0c559c)
revealed two silent bugs in Task 0.4's grad_ratio_mag_dir accessor:

  Bug A — readback size mismatch: pinned buffer allocated at
  total_params, but grad_buf length is total_params+cutlass_tile_pad,
  so size check always failed → Err silently coerced to 0.0 by proxy.

  Bug B — readback timing wipe: estimate_avg_q_value_with_early_stopping
  in process_epoch_boundary replays the forward graph, which zeros
  grad_buf. Any subsequent readback sees all zeros.

Both fixed in 41b0c559c; snapshot hoisted to top of process_epoch_boundary.

With those bugs fixed, the measured gradient ratio is NOT 0.0000 — it is
50–400× mag/dir across 60 epochs. Magnitude branch is over-fed, not
starved. Direction gradient is small but non-zero (~2e-2 to 7e0).
Direction policy is observably healthy (Short/Hold/Long/Flat 38/12/42/14%).

Track 1 triage's H4 CONFIRMED verdict was a measurement artefact
produced by Bugs A+B. H4 as originally defined is now REJECTED.

Plan changes:
  - Add new "Task 2.0 findings" section after cross-cutting concerns,
    documenting bug A, bug B, observed data, and revised strategy.
  - Task 2.1 marked DEFERRED (not deleted — kept for reference).
  - Task 2.2 promoted to PRIMARY fix.
  - New fallback: H9 delete-magnitude-branch as replacement for Task 2.1
    if Task 2.2 alone is insufficient.
  - Task 2.0 inventory row updated with LANDED status and commit chain.

Net effect: Phase 2 simplifies. The hardest task (2.1 three-branch
architectural fix) is skipped; primary path is Task 2.2 (~25 LOC).
2026-04-22 11:13:48 +02:00
jgrusewski
41b0c559c9 diag(policy-quality): Task 2.0 confirmation — expose absolute grad_dir / grad_mag norms
Task 0.4's grad_ratio_mag_dir returns 0.0 whenever dir_norm < 1e-9, so
the epoch-end reading of 0.0000 doesn't disambiguate "magnitude
starved" vs "direction starved". Task 2.0's per-component data showed
CQL and C51 each sending 100-400x more gradient to magnitude than
direction — implying direction is the starved one, not magnitude.

This adds a HEALTH_DIAG field exposing the raw absolute norms:

  grad_abs [dir=<sci-notation> mag=<sci-notation>]

Along the way uncovered + fixed two latent bugs that had been silently
zeroing the ratio signal since Task 0.4 landed:

1. `per_branch_grad_norms` read `grad_buf.len()` = total_params +
   cutlass_tile_pad (~4096 elements of GEMM tile padding) but the
   pinned readback slot was sized at construction to total_params
   exactly. The size check `grad_len > grad_readback_pinned_capacity`
   was always true, so the accessor returned Err on every call — and
   FusedTrainingCtx's proxy coerced Err to 0.0 via `.unwrap_or(0.0)`.
   Root cause for the "always 0.0000" grad_ratio_mag_dir. Fix: read
   only the first `total_params` prefix of grad_buf (the tail is pure
   GEMM padding, never holds gradient values).

2. Readback timing: process_epoch_boundary calls
   estimate_avg_q_value_with_early_stopping early, which replays
   `eval_forward_exec` — the SAME captured graph as forward_child
   whose first op is `cuMemsetD32Async(grad_buf, 0, total_params)`.
   Any grad_buf readback AFTER the avg_q call sees all zeros. Fix:
   snapshot grad_dir_abs / grad_mag_abs / grad_ratio_mag_dir at the
   TOP of process_epoch_boundary, before avg_q runs, and consume the
   cached values in the HEALTH_DIAG block.

Last 5 epochs of fold 3 on the magnitude_distribution baseline smoke
(FOXHUNT_TEST_DATA=test_data/futures-baseline):

  HEALTH_DIAG[15] ratio=42.85   grad_abs [dir=6.804900e0  mag=3.989081e2]
  HEALTH_DIAG[16] ratio=232.66  grad_abs [dir=6.500046e0  mag=3.603353e0]
  HEALTH_DIAG[17] ratio=318.34  grad_abs [dir=2.720317e-2 mag=1.713861e1]
  HEALTH_DIAG[18] ratio=367.59  grad_abs [dir=5.180866e-2 mag=8.076681e0]
  HEALTH_DIAG[19] ratio=298.55  grad_abs [dir=1.989553e-2 mag=6.498848e0]

Across all 60 epoch-boundary readings (3 folds × 20 epochs) dir ∈
[~4e-3, ~6e0] and mag ∈ [~5e-2, ~5e2], with ratio mag/dir consistently
50-400× (matching Task 2.0's per-component ratios). Neither branch is
near float precision — direction is PROPORTIONALLY starved, not
numerically zero.

Scenario confirmed: direction is starved relative to magnitude, NOT
the reverse. Phase 2's Task 2.1 (architectural fix on magnitude
branch) should pivot toward increasing direction's gradient flow
instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:00:28 +02:00
jgrusewski
980f3b07f3 diag(policy-quality): Task 2.0 extension — instrument 5 more grad writers
First Task 2.0 pass (commit d60e5375a) covered IQN/CQL/C51/Ens. Result:
IQN+Ens are architecturally zero (don't touch branches); C51+CQL send
30-400× more gradient to magnitude than direction. But Task 0.4's
grad_ratio_mag_dir=0.0000 at epoch end — so magnitude gradient gets
cancelled somewhere in the aux-graph gap between CQL/C51 and epoch end.

This commit instruments the 5 unmeasured writers/scalings:
  - apply_distillation_gradient
  - launch_recursive_confidence_backward
  - compute_predictive_coding_loss
  - apply_c51_budget_scale (multiplicative; signed delta reveals shrinkage)
  - apply_cql_saxpy (multiplicative)

Schema extended 4 → 9 components. Pinned result slot grew 8 → 18 floats.
9 device scratch buffers (~90 MB total on RTX 3050 Ti — 2.2% of 4 GB,
acceptable for diagnostic).

HEALTH_DIAG now emits:
  grad_split_bwd [iqn=… cql=… c51=… ens=…]
  grad_split_aux [distill=… rec=… pred=… cql_sx=… c51_bs=…]

No atomicAdd (block-internal tree reduction). memcpy_dtod_async is not
captureable → used graph_safe_copy_f32 (same as first pass).

Last 5 epochs (HEALTH_DIAG[15..19]), RTX 3050 Ti, magnitude_distribution
smoke test:

  grad_split_bwd [iqn=0.0000 cql=18.4618  c51=102.2001 ens=0.0000]
  grad_split_aux [distill=1.2281 rec=0.0000 pred=0.0000 cql_sx=18.4618  c51_bs=102.2001]

  grad_split_bwd [iqn=0.0000 cql=55.0089  c51=125.1374 ens=0.0000]
  grad_split_aux [distill=1.2381 rec=0.0000 pred=0.0000 cql_sx=55.0089  c51_bs=125.1374]

  grad_split_bwd [iqn=0.0000 cql=30.1931  c51=262.0410 ens=0.0000]
  grad_split_aux [distill=0.7916 rec=0.0000 pred=0.0000 cql_sx=30.1931  c51_bs=262.0410]

  grad_split_bwd [iqn=0.0000 cql=70.2364  c51=126.6637 ens=0.0000]
  grad_split_aux [distill=0.7397 rec=0.0000 pred=0.0000 cql_sx=70.2364  c51_bs=126.6637]

  grad_split_bwd [iqn=0.0000 cql=103.3925 c51=104.4165 ens=0.0000]
  grad_split_aux [distill=0.8085 rec=0.0000 pred=0.0000 cql_sx=103.3925 c51_bs=104.4165]

Keystone findings for Task 2.1:

* rec (recursive confidence) + pred (predictive coding) report 0.0000
  every epoch — architecturally expected: MSE/predictive gradients flow
  through bw_d_h_s2 / trunk tensors 0..8 only, they never touch branch
  tensors 8..16. Same architectural-zero class as IQN+Ens.

* distill is the only aux writer that actually touches branches, but at
  ratios ~0.5–1.2 (nearly balanced mag/dir), so it cannot be the source
  of Task 0.4's grad_ratio_mag_dir=0 cancellation.

* cql_sx ≡ cql and c51_bs ≡ c51 exactly (ratio, not norm). This is
  expected — multiplicative scalings (saxpy budget / c51 budget scale)
  uniformly scale BOTH direction and magnitude slices, so the mag/dir
  RATIO is invariant. These ops change amplitude, not balance.

Conclusion: NONE of the 9 instrumented stages zero the magnitude signal.
CQL+C51 reach grad_buf with huge mag/dir ratios (30-400×); distill adds
a balanced micro-contribution; rec/pred/iqn/ens/scalings all architectur-
ally neutral for the mag/dir ratio. Yet Task 0.4's post-step
grad_ratio_mag_dir=0 (meaning dir < 1e-9).

Re-reading Task 0.4's impl: `per_branch_grad_norms` returns [dir, mag, …]
and the ratio is `mag / dir if dir > 1e-9 else 0.0`. So 0.0000 means
dir_norm is zero, NOT mag cancelled. Task 0.4's measurement runs at
EPOCH boundary after Adam consumes grad_buf — which means the "cancel"
is either (a) Adam consuming/zeroing grad_buf pre-readback, (b) the
next step's `submit_forward_ops_main` memset firing before Task 0.4's
DtoH reads, or (c) something Adam-related that drops dir to zero.

Five unmeasured writers verified present (all 5 exist via grep +
read — none renamed or deleted since the first pass's report). Writers
that DON'T touch branches (rec/pred) are now explicit 0.0000 evidence,
not speculation.

Feeds Task 2.1's decision tree: H4 is NOT caused by cancellation in
the aux-graph gap. Next hypothesis: Adam grad_buf lifecycle / Task 0.4
timing relative to memset.

Build clean; magnitude_distribution smoke green (21.90 s local, RTX 3050 Ti).
Test result: 1 passed; 0 failed.

Push status: deferred — first Task 2.0 pass reported network unreachable
(d60e5375a local-only). Will attempt in same commit cycle; if it fails
again, that's consistent with the prior pass's finding.

Per plan Task 2.0 extension (from Task 2.0 first-pass escalation report).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 10:35:53 +02:00
jgrusewski
d60e5375a9 diag(policy-quality): Task 2.0 — per-component grad decomposition for H4
Adds grad_mag_{iqn,cql,c51,ens} HEALTH_DIAG fields via in-graph
pinned-snapshot + in-graph reduction kernel (revised approach; first
Task 2.0 dispatch escalated BLOCKED on host-side-snapshots-inside-
captured-graph, plan revised at 5a4d56145 to use this pattern).

How it works:
  * Three CudaSlice<f32>[branch01_elems] per-component scratch buffers
    (iqn, cql, ens) + one permanent-zeros reference buffer for C51
    (grad_buf is memset-zeroed at start of submit_forward_ops_main —
    no pre-C51 snapshot needed). Snapshots only cover the branch 0+1
    slice (direction+magnitude, tensors 8..16), NOT TOTAL_PARAMS —
    ~420 KB per snapshot, 4× = ~1.7 MB device memory total.
  * BEFORE each component's backward (captured in graph):
    graph_safe_copy_f32 kernel copies grad_buf[branch01] → scratch.
    C51 uses the permanent-zeros reference buffer directly.
  * AFTER each component's backward (captured in graph):
    grad_component_delta_norm kernel (256 threads, one block) reads
    (grad_buf[dir_slice] − snapshot[0..dir_len]) and
    (grad_buf[mag_slice] − snapshot[dir_len..]), reduces via
    shared-memory tree reduction (NO atomicAdd), writes
    (mag_norm, dir_norm) into a pinned device-mapped 8-float result
    slot at the component's 2-float offset.
  * At epoch boundary: refresh_grad_component_norms() stream-syncs,
    reads the pinned slot (zero-copy via cuMemHostGetDevicePointer),
    populates host-side grad_component_norms_mag/_dir caches.
  * HEALTH_DIAG emits `grad_split [iqn=... cql=... c51=... ens=...]`
    with per-component magnitude/direction ratios.

Uses graph_safe_copy_f32 (already existing — see gpu_dqn_trainer.rs:3839
doc comment: "memcpy_dtod_async is NOT captured by CUDA Graph"). The
plan's claim that DtoD memcpy is captureable is contradicted by the
codebase's own history, so we follow the established pattern.

Matches Task 0.5 / 0.8 no-atomics pattern; matches Task 0.4's pinned
device-mapped pattern for kernel-written readback. New file:
crates/ml/src/cuda_pipeline/grad_decomp_kernel.cu (72 LOC, one kernel
+ header doc).

Zero per-step PCIe traffic: the 8-float pinned slot is device-mapped;
writes flow via the mapped device pointer during graph replay, reads
are direct host dereferences at epoch boundary.

20-epoch grad_split trajectory — final fold, epochs 15–19:

  grad_split [iqn=0.0000 cql=33.5947  c51=418.0461 ens=0.0000]
  grad_split [iqn=0.0000 cql=65.2418  c51=222.4561 ens=0.0000]
  grad_split [iqn=0.0000 cql=23.8915  c51=145.3589 ens=0.0000]
  grad_split [iqn=0.0000 cql=46.1190  c51=268.8549 ens=0.0000]
  grad_split [iqn=0.0000 cql=147.4817 c51=213.1188 ens=0.0000]

Findings for Task 2.1 decision tree:

  * IQN and Ensemble report 0.0000 ratios every epoch — architecturally
    expected: apply_iqn_trunk_gradient writes only to trunk tensors 0..4
    (w_s1/b_s1/w_s2/b_s2); run_ensemble_step flows through the value
    head only (tensors 4..8). Neither touches branches 8..24 by design.
  * C51 and CQL are the ONLY two loss components that write directly
    to branch head weights — and both send MORE gradient to magnitude
    than to direction (ratios routinely 50–400×).
  * Task 0.4's grad_ratio_mag_dir=0.0000 (reported post-all-accumulations,
    post-budget-scaling) vs per-component ratios >>1 shifts the
    diagnosis: gradient IS reaching magnitude from C51/CQL — the
    global mag/dir=0 at epoch boundary reflects cancellation with
    other writers (distill SAXPY, recursive_confidence_backward,
    predictive_coding_loss) that accumulate into grad_buf BETWEEN
    CQL and Ens, AND the heavy c51_budget_scale (0.70) + cql_budget
    (0.04) scaling that is applied AFTER our reduction.

This is H9 territory (data-driven magnitude Q-value collapse) more
than H4 (architectural gradient starvation) — the Task 2.1 decision
tree should pick branches A or C (init scale / direction-conditioning)
only after excluding data-driven factors, or skip straight to the
"revisit in Phase 3" row.

Per plan Task 2.0 (revised at 5a4d56145). Build clean; magnitude
distribution smoke green (21.10s local, RTX 3050 Ti).
2026-04-22 10:09:05 +02:00
jgrusewski
5a4d561451 plan(policy-quality): Task 2.0 revised approach — in-graph pinned snapshots
First Task 2.0 dispatch escalated BLOCKED: the four loss-component
backward kernels are captured inside the fused training graph, so
host-side snapshot-between-components isn't possible mid-graph without
a force-ungraphed diagnostic step (~210 LOC + cross-stream sync risk).

Revised approach (chosen after cost analysis):

  cudaMemcpyAsync(device → pinned host) IS captureable in a CUDA graph.
  Even better: DtoD into per-component scratch buffers, then an in-graph
  reduction kernel computes per-component (mag_norm, dir_norm) and writes
  8 floats to a pinned result slot. Only the 8-float result crosses
  PCIe (at epoch boundary), keeping per-step PCIe traffic to zero.

Changes to the plan's Step 2 + Step 3 + Step 4:
  - Step 2: added 4 device-side scratch buffers (one per component,
    ~10 MB each = 40 MB device) + 8-float pinned result slot + new
    reduction kernel grad_decomp_kernel.cu spec'd out.
  - Step 3: clarified that DtoD snapshot + backward + reduction kernel
    are ALL captured in the graph; graph replays them every step;
    no force-ungraphed dance needed.
  - Step 4: added refresh_grad_component_norms() accessor that reads
    the 8-float pinned slot at epoch boundary (zero-copy) and populates
    the host-side cache.

Approach matches Task 0.4 pattern (commit bb42c9963) extended four-fold.
No atomicAdd (reduction uses shared-mem tree), no non-captured replay,
no cross-stream sync risk.

LOC estimate: ~90 (was ~210 for the rejected option).
2026-04-22 09:46:17 +02:00
jgrusewski
2a54edc92d plan(policy-quality): Phase 2 plan refinements (tolerance bands + decision tables)
Three polish items from the plan review:

1. Task 2.4 Step 5 — replaced vague "must not regress" with a numeric
   tolerance-band table: multi_fold best_val_metric ±15% per fold,
   Best Sharpe ≥ 20 floor, and hard F_Half/F_Full ≥ 0.05 + cf_flip ≥ 0.1
   BLOCKERS. Anchors to the baseline metrics doc values captured at
   policy-quality-baseline.

2. Task 2.6 — converted the ad-hoc text decision at Step 1 into the same
   five-row decision table format Task 2.1 uses (DELETE / KEEP / KEEP-as-
   safety-net / INCONCLUSIVE / DELETE-because-inseparable). Step 2
   ablation got its own 4-row outcome table keyed to ent_mag delta,
   multi-fold Sharpe regression, and NaN appearance.

3. Task 2.7 Step 1 — added "Repeat 3× with different seeds" clause and
   explicit total wall-clock note: ~75 min (3 × 25 min) for the ablation
   pass before the Step 2 decision. Aligns with the Cross-cutting concern
   #6 about sample-noise rejection.

No task count change (still 11: 2.0–2.10). Net code-delta estimate
unchanged. Standing-rule compliance unchanged (no stubs / no atomics
/ no quickfixes / no hiding / no feature flags / no push-per-task).
2026-04-22 09:29:16 +02:00
jgrusewski
1ce99efc53 plan(policy-quality): Phase 2 implementation — synthesis of 4 tracks
Consolidates Phase 1 triage findings from Tracks 1-4 into an 11-task
Phase 2 plan at docs/superpowers/plans/2026-04-21-policy-quality-phase2.md.

Task inventory:
  - 2.0  Per-component gradient decomposition (H4 keystone diagnostic)
  - 2.1  H4 fix — magnitude-head gradient starvation (decision tree from 2.0)
  - 2.2  H10 fix — stable argmax tie-break at eval
  - 2.3  DELETE R5 micro-reward
  - 2.4  DELETE R6 loss-aversion; relocate neg-tail to C51 target smoothing
  - 2.5  Wiring-bug sweep (7 bugs: C1 fire, epsilon gen_range, if !true,
         sigma_mean scale, fold-boundary reset, stale docstring, C5 ISV null)
  - 2.6  E4 entropy-reg DELETE-or-KEEP (data-driven, post-2.0)
  - 2.7  C4 adaptive grad-clip ablation + DELETE-or-KEEP
  - 2.8  L40S validation run — all 4 tracks re-measured
  - 2.9  Mandatory-gate verification + phase3-results.md
  - 2.10 Tag policy-quality-phase2-complete (and policy-quality-v1 if soft pass)

Matches Phase 0/1 plan formatting (checkbox steps, concrete file paths,
code snippets, bash commands, per-task commit templates). References
project standing rules (no quickfixes, no stubs, no atomic-adds on hot
paths, no feature flags, no hiding errors) and the pinned-readback
pattern from Task 0.4 (commit bb42c9963).
2026-04-22 09:23:52 +02:00
jgrusewski
0611d32b06 docs(policy-quality): Track 3 controllers audit (Phase 1) — 0 load-bearing, 6 diagnostic, 1 candidate-for-delete
V7 audit of the 7 adaptive controllers named in spec §5.3 against the
baseline controller_activity smoke run (3 folds × 20 epochs = 60 epochs,
intervention-based fire detection per commit ed4b30b49):

* C1 anti_lr: DIAGNOSTIC (0/60; wiring surprise — fire detector reads
  base scheduler, not post-anti_lr LR, flagged for Phase 2 fix)
* C2 adaptive tau: DIAGNOSTIC (2/60; fires at fold boundaries only)
* C3 adaptive gamma: DIAGNOSTIC (1/60; pinned to floor by health-coupled
  correction; re-measure at L40S when health is real)
* C4 adaptive grad_clip: CANDIDATE FOR DELETE pending ablation (12/60,
  20% — above diagnostic, below load-bearing; needs Track 3 Step 2)
* C5 cql_alpha: DIAGNOSTIC (2/60; regime_stability gate uninformative
  at smoke scale)
* C6 cost_anneal: DIAGNOSTIC by design (deterministic sigmoid; fire_cost
  hard-coded false at training_loop.rs:2475)
* C7 base LR scheduler: DIAGNOSTIC by construction (pure open-loop, not
  in the closed-loop controller battery)

Cross-controller finding: no controller fires in > 50% of epochs, so
the policy is not currently held on the rails by adaptive intervention
at smoke scale. However, C2 / C3 / C5 are structurally inert here —
their trigger signals (health, regime_stability) are degenerate at
smoke scale, so their 0-ish fire rates are lower bounds, not
representative. Promote to final after L40S validation.

Wiring surprise (anti_lr): fire_lr detection reads lr_scheduler.get_lr()
*before* the anti_lr multiplier applies. Under smoke Constant LR this
reports 0 correctly-for-the-wrong-reason; under L40S Cosine/Linear it
will false-positive on pure decay drift. Recommend detecting via the
anti_mult != 1.0 branch directly (training_loop.rs:2936–2942) for an
unambiguous intervention semantic matching C4/C6.

Follow-ups flagged for Phase 2:
- Fix C1 fire-detection wiring before L40S re-run
- Run C4 grad-clip ablation (25 min, gates the final C4 verdict)
- Optional: reset fire_counts per fold to remove C2/C5 boundary artefact

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:09:09 +02:00
jgrusewski
207fce8778 docs(policy-quality): Track 2 reward audit (Phase 1) — 2 DELETE, 3 KEEP
V7 audit of reward terms R1-R8 against the baseline smoke run
(magnitude_distribution.rs, 3 folds x 20 epochs = 60 HEALTH_DIAG rows):

* R1 step_return: KEEP (base reward, denominator)
* R2 PopArt drift: PENDING (warmup-gated zero at smoke; re-check at L40S)
* R3 CF-flip: KEEP (49-74% contribution, dominates shaping)
* R4 trail_r: KEEP-WITH-CAVEAT (fold-3 dominance, trade-volume gated)
* R5 micro-reward: DELETE (micro_reward_scale=0 in smoke, intended)
* R6 loss-aversion: DELETE (sub-1% in 54/60 epochs, relocate protection)
* R7 segment-patience: ALREADY-REMOVED (stub deleted 83d524f86)
* R8 raw_returns_out: KEEP (load-bearing for Sharpe/MaxDD pipeline)

Cross-term finding: R3 is the dominant reward shaper at smoke scale;
every other shaping term is <=3% of reward magnitude except R4 in fold
3. Confirms Track 1's H4 gradient-starvation diagnosis is compounded
by thin shaping elsewhere in the reward landscape.

Follow-ups flagged for Phase 2:
- stale patience_mult docstring at experience_kernels.cu:1049
- R5 deletion gated on TD-propagation diagnostic (Task #8)
- R6 relocation to Q-target smoothing in C51 Bellman
2026-04-22 09:06:50 +02:00
jgrusewski
5c70c68a15 docs(policy-quality): Track 1 magnitude triage (Phase 1) — H4 + H10 CONFIRMED
Preliminary triage of spec §5.1 hypotheses H1–H10 using the Phase 0
baseline capture on RTX 3050 Ti. L40S validation pending per plan.

Verdicts:
  H1  PENDING     (needs forced-exploration instrumentation not yet wired)
  H2  REJECTED    var_scale=0.96 across 19/20 epochs; Var[Q] inactive at smoke scale
  H3  INCONCLUSIVE kelly degenerate (insufficient win/loss counts at smoke scale)
  H4  CONFIRMED   grad_ratio_mag_dir=0.0000 across 20/20 epochs (threshold <0.1)
  H5  REJECTED    ent_mag stays ≥0.98 throughout; no bootstrap collapse
  H6  REJECTED    Full fire rate (0) is lower than Quarter fire rate, not higher
  H7  REJECTED    vsn and sigma symmetric between mag and dir branches
  H8  REJECTED    target-net drift equal (mag=dir=0.001)
  H9  PENDING     (same instrumentation gap as H1)
  H10 CONFIRMED   training ent_mag=0.98, eval F_Quarter=100%

Synthesis: H4 is the root cause. Magnitude branch receives ~0 gradient →
weights stay near init → three magnitude Q-values near-identical → argmax
picks bin 0 (Quarter) on ties → H10 manifests at eval time. H2, H5, H7,
H8 all ruled out as contributors.

Proposed Phase 2 priority: fix H4 (gradient-flow path into magnitude head
— likely per-component advantage weighting or direction-conditioning of
w_b1fc) + H10 (Q-margin argmax + stochastic eval rollouts as safety net).

Phase 1 next: validate preliminary verdicts on L40S, instrument
per-component gradient decomposition for magnitude, proceed with
Tracks 2/3/4 in parallel.
2026-04-22 08:51:51 +02:00
jgrusewski
8ab368de58 docs(policy-quality): Task 0.17 baseline metrics — captured (Phase 0 complete)
All <PENDING> markers replaced with real values from a clean 20-epoch
magnitude_distribution smoke run + 3-fold multi_fold_convergence run
on RTX 3050 Ti at HEAD 0472b9730.

All 5 Phase 0 smoke tests PASS:
  magnitude_distribution  — Quarter=0.596 Half=0.150 Full=0.255
  reward_component_audit  — cf_flip=0.619 trail=0.290 la=0.013
  controller_activity     — all 6 controllers < 20% firing
  exploration_coverage    — ent_mag @ep5=0.987 @ep20=0.985
  multi_fold_convergence  — 3/3 folds, Best Sharpe 51 / 39 / 87

This commit closes Phase 0. The next commit tags it as
policy-quality-baseline — the reference state against which Phase 2
interventions are measured.
2026-04-22 08:42:50 +02:00
jgrusewski
0472b97300 test(smoke): strengthen performance probes with meaningful thresholds
Previously test_training_throughput_measurement and test_real_data_single_epoch
only asserted loss.is_finite() on the final metric. That passes on trivial
zeros, on huge-but-finite NaN-disguised values, and on any regression that
doesn't produce literal NaN — giving effectively no signal.

test_training_throughput_measurement now asserts:
  - loss finite AND non-negative
  - epochs_trained >= 1
  - throughput floor: epochs_per_sec > 0.05 (i.e. each epoch < 20s on the
    RTX 3050 Ti; catches accidental CPU fallback or kernel CPU-pinning).
    Documented as a conservative local floor; CI may tighten.
  - avg_q_value present, finite, |avg_q| < 1e6 (rules out finite-but-huge
    NaN propagation)

test_real_data_single_epoch now asserts:
  - loss finite, non-negative, and < 1e8 (a real DQN loss of 0.0 is a
    sign-bug or accumulation-bug tell; huge-but-finite rules out NaN
    propagation)
  - epochs_trained >= 1
  - avg_q_value finite and |avg_q| < 1e6

Why these are safe:
  - Bounds are chosen from observed smoke runs with 2-3 orders of margin.
  - Passes locally in 1.58s and 10.24s respectively.
  - Designed to flag regressions, not true production-scale deviations.

Verified PASS on laptop (RTX 3050 Ti).
2026-04-22 08:24:53 +02:00
jgrusewski
2570fe0130 fix(smoke): test_fxcache_zero_copy_training — real failure detection
Previously the fold loop swallowed every training error into f64::NAN,
unconditionally pushed a value at the top of the loop, and then asserted
only !fold_losses.is_empty() — a tautology that could never fail. Any
CUDA error, NaN explosion, or regression of the zero-copy path passed
silently.

Now:
  - Error propagation via `?` (no swallow). A zero-copy test can't
    tolerate training failures — if training blew up, the zero-copy
    wiring is broken and must surface.
  - All fold losses must be finite and non-negative.
  - Every fold must report epochs_trained >= 1 (zero-epoch fold =
    kernel skipped or buffer not populated).

A direct "no htod/dtoh copy happened" assertion would need a copy-counter
instrumented into the fused-training GPU path plus a field on
TrainingMetrics. That is out of scope for this test; documented inline.

Verified PASS on laptop (RTX 3050 Ti):
  loss=0.0018366, epochs=1
2026-04-22 08:22:00 +02:00
jgrusewski
e8ecb2f626 fix(training_loop): surface FusedTrainingCtx init errors instead of hiding them
Both lazy-init sites (train_with_data_full_loop_slices @ L344 and
run_training_steps_slices @ L1511) previously logged FusedTrainingCtx::new
failures via tracing::error! and continued with fused_ctx = None. On CUDA
builds there is no CPU fallback, so the next stage (GPU experience collector)
then fails ~100ms later with a misleading "GPU experience collector MUST be
active for CUDA training" error that obscures the real CUDA root cause (OOM,
driver error, etc.).

Both sites now propagate the original error via map_err → anyhow::anyhow! so
callers see the actual failure. The post-init wiring (PER buffer pointers,
RNG dev ptr) is refactored from `if let Some(ref mut fused)` — which was
silently-no-op on the hidden-error path — to an unconditional
as_mut().expect() since fused_ctx is guaranteed Some after the `?`.

The two remaining `fused_ctx = None` assignments are legitimate:
  - mod.rs:655 in Drop (ordered GPU teardown)
  - training_loop.rs:1508 batch-size-change recreate (immediately reassigned
    by the ? on the next line)

No new API, no threshold changes, no feature flags.
2026-04-22 08:18:43 +02:00
jgrusewski
0beccd5e82 fix(smoke): multi_fold_convergence — laptop-sized config
Two wrong-scale assumptions in the test made it unachievable on the
RTX 3050 Ti / 4 GB laptop this smoke is meant to run on:

1. `train_baseline_rl` was invoked without `--training-profile`, so it
   defaulted to `dqn-production`: batch_size=16384, buffer=500k,
   num_atoms=52, hidden_dim_base=256. Fused-CUDA init OOMs at
   `kan_d_coeff_per_elem alloc` on 4 GB, leaving `fused_ctx = None` and
   every subsequent fold failing with "GPU experience collector MUST be
   active for CUDA training". Fix: pass `--training-profile=dqn-smoketest`.

2. Default walk-forward windows (12 train / 3 val / 3 test / 3 step) only
   yield 2 folds in the 24-month baseline dataset — fold 2's test-end
   lands one month past `data_end`. The test's pass-gate is "≥2/3 folds
   produce a checkpoint", so a test that can only ever generate 2 folds
   is degenerate. Fix: explicit shorter windows (6 / 2 / 2, step 2) that
   yield all 3 folds (`6 + 2*2 + 2 + 2 = 14 ≤ 24`, comfortable margin).

Also drops `--epochs 20` → `--epochs 5`. Each fold runs ~5500 batches at
~33 s/epoch on this GPU; 20 × 3 folds ≈ 33 min was exceeding the smoke
budget (kill observed around the 10-minute mark). 5 epochs is ample for
the checkpoint gate — `best_sharpe` saves on the first improving epoch
(epoch 1 in practice), so more epochs add no pass/fail signal, only
wall-clock.

Verified locally: 3/3 folds produce `dqn_fold{N}_best.safetensors`,
total wall-clock ~7 min.

    [MULTI_FOLD] fold 0 checkpoint OK
    [MULTI_FOLD] fold 1 checkpoint OK
    [MULTI_FOLD] fold 2 checkpoint OK
    test result: ok. 1 passed; 0 failed ... finished in 416.36s

Docstring updated to reflect new sizing and call out the 4 GB / 24-month
constraints explicitly so the next person reading this can see why the
numbers are what they are.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 02:01:47 +02:00
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