Commit Graph

117 Commits

Author SHA1 Message Date
jgrusewski
779c03b9d7 feat(ml-alpha): Phase 1B-B — rollout collection loop + GAE behind FOXHUNT_USE_ROLLOUT flag
Spec: docs/superpowers/specs/2026-06-02-trainer-rollout-buffer-gae.md
Plan: docs/superpowers/plans/2026-06-02-trainer-rollout-buffer-gae-implementation.md §Phase 1B-B

Builds on commit 5cd2f8703 (1B-A foundation). Wires the RolloutBuffer
into the per-step training loop via a new RolloutCollection helper,
gated behind FOXHUNT_USE_ROLLOUT=1 env flag. Mode 0 (default unset) is
bit-equal to HEAD; Mode 1 (flag=1) snapshots per-step data into the
rollout buffer and invokes GAE every T_rollout steps.

This phase is a STRUCTURAL validation, not an empirical pnl test.
Phase 1B-C will replace the per-step training in Mode 1 with multi-
epoch PPO over the rollout buffer.

New components:

* crates/ml-alpha/cuda/rollout_pack.cu (42 LOC):
    Single-thread-per-batch deterministic f32 -> u8 packer for dones.
    Used because RolloutBuffer.dones_d is u8 (per spec for memory) but
    the trainer's dones_d is f32 (per existing pipeline).

* crates/ml-alpha/src/trainer/rollout_collection.rs (375 LOC):
    RolloutCollection helper with three methods:
      - new(ctx): loads rollout_pack cubin
      - snapshot_step(trainer, buf, t, b_size, t_max): DtoD scatter via
        cuMemcpy2DAsync_v2 for rewards/v_t/actions/log_pi/h_t at offset
        b*t_max+t in the [B × T] buffer; rollout_pack kernel for dones
      - copy_bootstrap_v(trainer, buf, b_size): DtoD V(s_T) from
        trainer.v_pred_tp1_d into buf.v_t_bootstrap_d at rollout end
    All transfers use mapped-pinned / DtoD only — no raw memcpy_dtoh
    on regular slices (`feedback_no_htod_htoh_only_mapped_pinned`).

* crates/ml-alpha/examples/alpha_rl_train.rs (+137 LOC at lines 59-66,
  410-466, 591-666):
    - FOXHUNT_USE_ROLLOUT env-flag parse at startup
    - Conditional RolloutBuffer + RolloutCollection allocation
    - Per-step snapshot_step() after step_with_lobsim_gpu
    - Every T_rollout steps: copy_bootstrap_v + compute_gae + stderr log
      `[rollout] step=N GAE complete (T=…, γ=…, λ=0.95): adv_mean=…
       adv_abs_mean=… returns_mean=…`

Adaptations from the dispatch (documented inline):

1. T_rollout fallback to 32 when slot 758 is unbootstrapped. Slot 758
   (RL_PPO_ROLLOUT_HORIZON_INDEX) was allocated in 1B-A but no kernel
   bootstraps it — `read_isv_host(758)` returns 0.0. Binary uses
   `.clamp(32, 1024)`, flooring to T=32 for the smoke. Phase 1B-C will
   add the trainer bootstrap entry (or a controller kernel) to make
   T_rollout=256 the production default.

2. Mode 1 still runs per-step training as a side effect; the rollout
   buffer is populated as a parallel snapshot. The "stop training in
   Mode 1" separation would require a ~1500 LOC refactor of
   step_with_lobsim_gpu (single-step/training-coupled per
   pearl_foxhunt_trainer_is_genuinely_single_step). The dispatch
   explicitly authorized this "skeleton with correct semantics"
   adaptation — the load-bearing 1B-B gate is "collection + GAE
   pipeline works structurally", which is verified. Phase 1B-C will
   replace the per-step training with multi-epoch PPO over the rollout
   buffer.

Validation (all gates PASS):
* cargo build --release --example alpha_rl_train -p ml-alpha: exit 0 (~60s)
* cargo test rollout_buffer_invariants --release: 5/5 PASS (no regression)
* Mode 0 (flag unset, default): ./scripts/determinism-check.sh --quick exit 0
  - DETERMINISTIC: all checksums.* leaves match across all 200 rows
  - pipeline output bit-equal to HEAD 5cd2f8703 baseline
* Mode 1 (FOXHUNT_USE_ROLLOUT=1): smoke completes in 2m 53s
  - 500 train + 100 eval steps, exit 0, completed_clean=true
  - 16 GAE windows logged (T=32 each)
  - adv_abs_mean range [4.4e-3, 1.92e-1] — always > 1e-3 sanity gate
  - eval_summary written: total_pnl_usd=-$101,487.70, n_trades=301, wr=0.346
* Cross-source consistency BOTH modes within $50 (Mode 0: $0.00 diff;
  Mode 1: $0.01 diff). Phase 0 contract preserved.
* Pre-commit hook PASS (0 memcpy_dtoh raw violations, 0 atomicAdd).

Next: Phase 1B-C will (a) bootstrap slot 758 to production T_rollout=256,
(b) replace Mode 1's per-step training with multi-epoch PPO over the
rollout buffer using the existing PPO surrogate + V regression kernels
operating on minibatches of [B × T] flattened to [B*T].

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 22:42:39 +02:00
jgrusewski
cfaa420bd4 fix(ml-alpha): Phase 0 — per-step authoritative USD pnl in diag + fix mislabeled fields
The diag's `trading.*_pnl_*_usd` fields were systematically mislabeled, making
the eval verdict signal untrustworthy. Three quantities shared the `_usd`
suffix but were not in USD:

  - `eval_summary.total_pnl_usd` (USD ground truth, $50/pt ES applied)
  - `trading.realized_pnl_cum_usd` (cumulative SHAPED reward at close events;
    pts × lots × Phase-5 shaping; NOT USD despite the suffix)
  - `trading.pnl_cum_usd` (mathematically nonsense — reads rewards_d after
    apply_reward_scale AND rl_popart_normalize whiten it in-place; dividing
    by current_scale un-does only the first transform)

At baseline mid-smoke (b=128, 2000+500, seed=42): eval_summary.total_pnl_usd
= -$4,457,625 while diag.trading.realized_pnl_cum_usd = +$469k and
diag.trading.pnl_cum_usd = +$12.9k. 346x ratio, sign-opposite. Same
mechanism as `pearl_grwwh_eval_catastrophic_collapse` ($234M cluster
discrepancy). Every Pearson(reward, Δpnl_usd) computation against these
fields was shaped-vs-shaped tautology, not pnl alignment.

This commit:

  DELETE `trading.pnl_cum_usd` (mathematically nonsense post-popart).

  RENAME `trading.realized_pnl_cum_usd` -> `trading.shaped_reward_close_event_cum`.
  Truthful name; the underlying values are post-Phase-5 shaped reward
  summed at close events, useful for gradient-signal diagnostics but never
  to be confused with USD pnl.

  ADD `trading.realized_pnl_usd_delta` and `trading.realized_pnl_usd_cum` in
  diag.jsonl / eval_diag.jsonl. Source: new per-batch float buffer
  pnl_step_close_usd_d on LobSimCuda, zeroed via raw_memset_d8_zero before
  each pnl_track_step launch, written by pnl_track.cu's close branch as
  realised_pnl_usd_fp / 100.0 (same arithmetic as eval_summary's
  TradeRecord aggregator, applying ES $50/pt). Single-writer per block —
  no atomicAdd per feedback_no_atomicadd.

  Direct readback via read_slice_d_into<f32> from sim.pnl_step_close_usd_d()
  after step_with_lobsim_gpu returns (main stream already synchronized via
  self.stream.synchronize() at pnl_track_step exit). NOT routed through
  diag_staging double-buffer — a prior implementation tried this and
  broke determinism (cross-stream race between main-stream
  raw_memset_d8_zero + pnl_track_step write and diag-stream
  raw_memcpy_dtod_async read). Direct same-stream read avoids the race
  entirely and stays mega-graph compatible (the readback runs AFTER
  per-step pipeline finishes, outside any captured CUDA graph).

  Cumulative tracked Rust-side in alpha_rl_train.rs; reset at train->eval
  boundary so realized_pnl_usd_cum tracks the eval window only.

  scripts/tier1_5_verdict.py upgrade:
  - signal_reward_alignment reads trading.realized_pnl_usd_cum
  - signal_eval_pnl falls back to realized_pnl_usd_cum
  - signal_consistency upgraded from WARN-at-5% to KILL-at-$50 with a new
    consistency_kill_usd threshold key; cross-source disagreement is now
    a hard kill not a warning

  tests/eval_diag_emission.rs: bumped EXPECTED_LEAVES 711 -> 712 (-1 deleted,
  -0 renamed, +2 added); added invariant that cum == running_sum(delta) and
  regression check that legacy pnl_cum_usd / realized_pnl_cum_usd fields are
  absent from the schema; allowed n_eval_steps + 1 row count for the
  drain-row pattern.

Falsification gate (load-bearing, must hold every smoke from this commit
forward): `diag.last_eval_row.trading.realized_pnl_usd_cum` matches
`eval_summary.total_pnl_usd` within $50.

Validation (mid-smoke b=128, 2000 train + 500 eval, seed=42, RTX 3050):
  - cargo build --release --example alpha_rl_train -p ml-alpha: exit 0
  - cargo test -p ml-alpha --test eval_diag_emission: PASS (712 leaves)
  - local-mid-smoke.sh: exit 0, drain row at step 2500
  - cross-source consistency: eval_summary=-$4,457,625.00 vs
    diag.realized_pnl_usd_cum=-$4,457,625.18 -> delta=$0.18 (exactly fp/100
    f32 rounding noise; well within $50 tolerance)
  - ./scripts/determinism-check.sh --quick: PASS — all checksums.* leaves
    match across all 200 rows (rel-tol 1e-5, abs-tol 1e-7)
  - Pre-commit hook on staged diff: PASS

Memory pearls created in this session document the diagnostic chain:
  - pearl_diag_pnl_fields_are_shaped_reward_not_usd (the mislabeling root)
  - pearl_reward_signal_anti_aligned_with_pnl ADDENDUM 2026-06-02b
    (correction to the 2026-06-02 ADDENDUM — the "+0.93 Pearson at HEAD"
    finding was a measurement artifact because both sides were in shaped
    pts x lots units, not USD)
  - pearl_advantage_kernel_is_done_gated_td_not_gae (related signal-density
    gap — Phase 1.5 follow-up)
  - pearl_phase5_term_4_is_almost_potential (Ng-Harada-Russell analysis of
    Phase 5 shaping terms — Phase 1 follow-up)

Unlocks: TRUE Pearson(rewards.sum, Δpnl_usd) can now be measured per-step
at HEAD. The eval-collapse investigation (next phases: Ng-Harada-Russell
shaping cleanup + GAE upgrade) is now falsifiable with bit-deterministic
verdicts grounded in authoritative USD pnl.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 19:55:31 +02:00
jgrusewski
629ebd667c feat(ml-alpha): deterministic same-seed training + Tier 1.5 fast-dev-cycle
Two same-seed runs now produce bit-equal eval_summary.json, alpha_rl_train_summary.json,
and diag.jsonl (modulo wall-clock elapsed_s). The 5-phase falsification chain landed:

  Phase 2   PER tree-rebuild: __threadfence is NOT a grid-wide barrier; multiple blocks
            raced across sum-tree levels. Fix: Grid=(1) Block=(1024) + __syncthreads
            in rl_per_tree_rebuild.cu.

  Phase 2.3 cuBLAS GEMM_DFALT + TF32 default-math allowed split-K non-deterministic
            accumulation at 3 sites. New crates/ml-alpha/src/cublas_determinism.rs
            applies CUBLAS_PEDANTIC_MATH via FOXHUNT_DETERMINISTIC env toggle
            (0=TF32 prod, 1=PEDANTIC dev default, 2=DEFAULT_MATH control).

  Phase 2.6 Two bugs surfaced sequentially in the backward kernel chain:
            (1) rl_iqn_tau_cos_features had a multi-block r/w race on prng_state[batch]
                — all N_TAU=32 blocks read seed; only tau_idx==0 wrote back; no
                inter-block barrier. Fix: split into READ-ONLY rl_iqn_tau_cos_features
                + new sibling rl_iqn_advance_prng_state launched on same stream
                (kernel-launch ordering = grid-wide barrier).
            (2) OutcomeHead::new called near_zero_xavier without scoped_init_seed,
                falling back to time+thread-id RNG. Stayed dormant until first done
                event activated non-sentinel labels and divergent weights flowed via
                grad_h_t_outcome into encoder gradient. Fix: add seed param + install
                scoped_init_seed(dqn_seed.wrapping_add(0x0CE0)) guard.

Validation (./scripts/determinism-check.sh --quick, RTX 3050, b=128, 200+50 steps):
  - All 200 rows of checksums.* leaves match (rel-tol 1e-5, abs-tol 1e-7)
  - eval_summary.json, alpha_rl_train_summary.json byte-equal between runs
  - diag.jsonl byte-equal modulo elapsed_s
  - Eval pnl identical run-A vs run-B at seed 42

Pre-fix baseline (Phase 2.5 measurement): same-seed eval pnl spread $450k
($187k vs -$261k). Post-fix: $0 spread.

Speed cost: ~1.5ms/step amortised; ~10-15% slower than TF32 production
(PEDANTIC tax — acceptable in dev, toggle to FOXHUNT_DETERMINISTIC=0 for prod).

Mapped-pinned discipline: all 11 NEW memcpy_dtoh sites in diagnostic dump methods
+ per-step checksum readback use a new pub(crate) helper
read_slice_d_into<T: Copy>(stream, src, dst) — MappedRecordBuffer + raw
memcpy_dtod_async + raw_stream_sync + volatile read. Generic over T (f32, f64,
i32, u32, u8). Satisfies feedback_no_htod_htoh_only_mapped_pinned + hook guard.

Bundled Tier 1.5 fast-dev-cycle infrastructure (spec
docs/superpowers/specs/2026-06-02-fast-dev-cycle.md):
  - scripts/local-mid-smoke.sh        b=128, 2000+500, ~10min on RTX 3050
  - scripts/determinism-check.sh      runs mid-smoke twice, diffs checksums
  - scripts/tier1_5_verdict.py        behavioral kill verdict
  - AdamW checkpoint save/load (crates/ml-alpha/src/trainer/optim.rs)
  - IntegratedTrainer checkpoint save/load (resume from checkpoint)
  - 15 Phase 1 checksum leaves in build_diag_value
  - Env-gated dump methods (FOXHUNT_DETERMINISM_DEBUG_PER/MAMBA2/RL/BACKWARD)
    for future divergence-chasing — never run in production

Documentation:
  - docs/superpowers/specs/2026-06-02-determinism-foundation.md
  - docs/superpowers/specs/2026-06-02-fast-dev-cycle.md
  - docs/superpowers/plans/2026-06-02-determinism-foundation-implementation.md
  - docs/superpowers/notes/2026-06-02-determinism-phase{1,2,2.2,2.5,2.6}-*.md
  - Adjacent specs/plans/notes from the analytical chain that surfaced determinism
    as the load-bearing blocker (eval-summary, eval-boundary, regime-observer,
    multi-head policy, regime-invariance, Phase 3 IQN-complement post-mortem)

Unlocks: every controller / architecture / reward-shaping A/B from this commit
onward attributes outcome differences to the change, not random-init kernel-race
drift cascading through training x eval LOB-sim trajectories. The eval-collapse
investigation (pearl_reward_signal_anti_aligned_with_pnl, multi-head spec,
regime-invariance spec) is now testable with trustworthy verdicts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 17:56:00 +02:00
jgrusewski
55d049ecf4 feat(rl): B-7 ISV-toggle reward clamp (default disabled)
apply_reward_scale.cu's post-scale [-3,+1] clamp was masking ~99.99% of
realized tail magnitudes from the agent's reward signal: local b=16 smoke
showed train pnl_cum_usd −$0.63 (clamp-truncated) vs realized_pnl_cum_usd
−$8,574.63 (raw raw_rewards sum), a 13,600× compression. Per van Hasselt
2016, popart standardization + F4 envelope are designed to handle tail
magnitudes; the clamp fights them.

Changes:
- RL_REWARD_CLAMP_ENABLED_INDEX (slot 724): default 0 (disabled). When 0
  the kernel skips the asymmetric clamp; scaled rewards pass through to
  rewards[b] unchanged. Legacy behavior restored by setting to 1.
- DiagInputs.realized_pnl_cum_usd: parallel counter computed from
  raw_rewards (pre-scale, pre-clamp shaped pnl). Compare against
  trading.pnl_cum_usd to surface clamp-truncation gaps.
- Trainer accumulates realized_pnl_cum_usd in both train + eval loops
  per closed-trade done-step (same pattern as pnl_cum_usd).
- EXPECTED_LEAVES 651 → 652 for the new diag leaf.

Validation:
- 200+100 b=16 fold-1 smoke clean; train leaves = eval leaves = 652;
  realized_pnl_cum_usd diverges from pnl_cum_usd as expected when clamp
  disabled (the reward-hacking gap is now observable).
- compute-sanitizer pending (cluster).

B-8 (popart σ_welford disaggregation) and B-9 (C51 Bellman-target
saturation observability) specs at docs/superpowers/specs/2026-06-01-*
build on this slot allocation (725, 726-729 next).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 10:05:59 +02:00
jgrusewski
22e6ddbcac fix(rl): eval_summary aggregates across all b_size accounts (E.4-E.5)
Replaces the broken single-account + scale-mismatch slicing block in
alpha_rl_train.rs eval phase with proper per-account aggregation.

Pre-fix (cluster v11 alpha-rl-8ll7j, b=1024):
  head_before_eval = sim.read_total_trade_count()  // aggregate=1,203,376
  all_records      = sim.read_trade_records(0)     // backtest 0 only, ≤1024
  // 1.2M > 1024 → wrap branch fires → eval_records = ALL 1024 records
  //   from account 0, mixing train+eval trades
  eval_summary: n_trades=1024 pnl=$61,513 wr=0.217 — MEANINGLESS

Post-fix (this commit, b=16 local smoke):
  head_before_per_b = sim.read_per_backtest_trade_counts()
  all_per_b         = sim.read_trade_records_all()
  // for each backtest: slice ring by per-account head_before vs head_after,
  //   handle wrap correctly, aggregate eval-only records
  eval_summary: n_trades=226 pnl=$-41,137 wr=0.376
  n_eval_trades_seen=226 (= captured) n_dropped=0 n_pre_eval_wrapped=0

Local smoke validation (b=16, 1k train + 250 eval, fold 1 ES.FUT all):
  - n_trades jumped 58 → 226 (4× — confirms aggregation across 16 accts)
  - per-account pre-eval heads: min=16 max=59 sum=596 (correct scale)
  - n_eval_trades_seen == n_trades (no wrap at this scale)
  - eval_summary.json gained 4 new fields:
      n_eval_trades_seen, n_eval_trades_dropped,
      n_pre_eval_trades_wrapped, b_size

Existing keys (n_trades, total_pnl_usd, profit_factor, sharpe_ann,
max_drawdown_usd, win_rate) preserved for downstream G8-gate / Argo
aggregator consumers (per spec §3).

Cluster validation pending Phase A cluster (alpha-rl-jz48s) completion
to avoid alpha_rl_train.rs branch conflict. After Phase A merges,
this fix can submit alongside.

Spec: docs/superpowers/specs/2026-05-31-eval-summary-trade-aggregation-design.md
Plan: docs/superpowers/plans/2026-05-31-eval-summary-trade-aggregation.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 18:09:16 +02:00
jgrusewski
ad16e9d941 fix(trainer): address Phase A code review minors (8 fixes)
Per `feedback_always_fix_minor_review_findings`, all 8 minor review
findings from Phase A (eval-diag emission) are applied:

1. Restore aliasing comment for trail_fired_step / conf_gate_step
   (both read RL_CONF_GATE_FIRED_COUNT_INDEX deliberately).
2. Replace act_hist[7/8/9/10] magic indices with `Action::*` enum
   variants in both `IntegratedTrainer::build_diag_value` and the
   train/eval loops in `alpha_rl_train.rs`.
3. Rewrite `recursion_limit = "256"` comment in `lib.rs` to describe
   macro recursion DEPTH (~30 nested object blocks), not leaf count.
4. Move `RL_CONF_GATE_FIRED_COUNT_INDEX`, `RL_PYRAMID_ADD_COUNT_INDEX`,
   `RL_FRD_GATE_FIRED_COUNT_INDEX`, `RL_HEAT_CAP_FIRED_COUNT_INDEX`
   to the top-of-file `use` block (each appears ≥2× as a fully-
   qualified path); 10 call sites switched to bare names.
5. Harmonise leaf-count documentation to 643 (642 scalars + 1 bool
   `pyramid.max_units_reached`) across `build_diag_value` docstring,
   `--eval-diag-jsonl` arg docs, eval-phase comment, and the test
   module-level docstring.
6. Drop `_host` suffix from every `DiagInputs` field (14 fields).
   The struct's docstring already states all slices are host-side;
   the suffix was redundant. `DiagStaging.*_host_ptr` fields are
   NOT renamed — those still refer to actual host pointers.
   28 internal trainer references and 28 call-site references
   updated in lockstep.
7. Align eval_diag_emission test default data dir with foxhunt
   convention: `test_data/futures-baseline/ES.FUT` (resolved from
   `CARGO_MANIFEST_DIR`) instead of `/tmp/rl-smoke-lpi-diag/data`.
   Switch `--instrument-mode` from `front-month` to `all` to match
   the all-instrument predecoded files at that path. Env override
   `FOXHUNT_EVAL_DIAG_DATA` still wins.
8. Add eval-step monotone assertion: verify `eval[0].step == n_steps`
   and `eval[N-1].step == n_steps + n_eval_steps - 1`. Catches a
   regression where the eval loop would reuse the training step
   counter instead of continuing past it.

Verification: `SQLX_OFFLINE=true cargo check -p ml-alpha` clean +
`cargo test --release -p ml-alpha --test eval_diag_emission --
--ignored --nocapture` PASS with curated data (100 train + 50 eval
lines, 643 leaves both phases, schema parity, step axis 100..=149).
2026-05-31 17:49:23 +02:00
jgrusewski
210794626a feat(rl): emit eval-phase per-step diag to eval_diag.jsonl
Cluster run alpha-rl-8ll7j ended with +$61,513 pnl and max_dd -$444,512
but the eval phase emitted ZERO per-step diag, leaving the drawdown
trajectory invisible. Phase A of the 2026-05-31 checkpoints+eval-diag
plan wires the eval loop into the same diag pipeline as train using
the builder extracted in the previous commit.

Changes:
  * `--eval-diag-jsonl <PATH>` CLI flag (defaults to
    `<out>/eval_diag.jsonl`).
  * Eval loop now calls `diag_staging.sync_and_swap` +
    `snapshot_async` after every `step_with_lobsim_gpu`, builds a
    `DiagInputs` from the staging reads, and writes a JSONL line via
    the same `IntegratedTrainer::build_diag_value` the train loop
    uses. Step indices continue past the train phase
    (`cli.n_steps + eval_step`) so post-hoc tooling can concatenate
    train + eval JSONL into a monotone step axis.
  * Eval-phase running counters (pnl_cum_usd, trades, gates, …) are
    independent of train counters so the eval JSONL reflects the
    eval window only — mirrors the trade-record checkpoint that
    eval_summary.json uses.
  * New integration test `eval_diag_emission` validates schema
    parity: same 643 leaf paths in `diag.jsonl` and `eval_diag.jsonl`,
    correct line counts (n_steps / n_eval_steps). Ignored by default
    because it requires CUDA + the pre-built release binary.

Verification (locally on RTX 3050 Ti):
  100 train + 50 eval @ b=16, n_folds=2 →
  `diff <(head -1 diag.jsonl | jq 'paths(scalars)|sort')
        <(head -1 eval_diag.jsonl | jq 'paths(scalars)|sort')`
  returns empty (schema parity confirmed).
2026-05-31 17:26:21 +02:00
jgrusewski
8a93a77adc refactor(trainer): extract json!{} into IntegratedTrainer::build_diag_value
The per-step diag JSONL `json!{...}` block had grown to 642 leaf paths
across ~30 nested objects, duplicating every ISV slot read into the
example binary. Phase A of the 2026-05-31 checkpoints+eval-diag plan
extracts it into a single builder on the trainer so the eval phase
can reuse it (next commit) without duplicating the schema.

Changes:
  * `IntegratedTrainer::build_diag_value(step, elapsed_s, &DiagInputs)
    -> Result<serde_json::Value>` — same 642-leaf schema as before,
    bit-equivalent ISV reads (all from `self.isv_host_slice()`).
  * `DiagInputs<'a>` struct bundles the host-side per-step state the
    trainer doesn't own (DiagStaging reads + running counters +
    windowed act histogram), so the call site stays a 1-liner.
  * Train loop in `alpha_rl_train.rs` swaps the inline json! for the
    builder call; the ~140-slot ISV-imports wall collapses to four
    slots still read by the stderr ticker.
  * `#![recursion_limit = "256"]` moves from the example into
    `ml-alpha/src/lib.rs` since the builder now lives in the library.

Schema parity verified: `head -1 diag.jsonl | jq 'paths(scalars)|sort'`
yields the same 642 keys as before this refactor (no schema drift).
2026-05-31 17:25:47 +02:00
jgrusewski
bf214d1a09 refactor(rl): v9 slot rename — RL_EVAL_WARMUP_* → RL_REGIME_TRANSITION_* (F3)
Theorem 2 (v9 behavior preserved): pure constant identifier rename across 3 files;
no physical migration. Slots 685/686/687 keep their addresses; v9 kernel,
trainer reset_session_state, and diag emit all reference the same memory.

- crates/ml-alpha/src/trainer/integrated.rs: 4 references renamed
- crates/ml-alpha/cuda/rl_eval_warmup_decay.cu: 3 `#define`s renamed
- crates/ml-alpha/examples/alpha_rl_train.rs: ~12 references renamed (import + diag emit)

Unblocks the 4 pre-existing errors that F1.1 introduced. Branch now compiles
cleanly with regime_observer foundation (F1) + v9 cleanup (F3).
2026-05-31 12:26:54 +02:00
jgrusewski
b09feaf9e4 feat(rl): regime_observer diag emit (F1.8)
Adds 'regime' block to risk_stack diag JSON, surfacing 5 nested groups:
- dead_zone: flag, duration, timeout_flag, max_duration
- tail: recency, session_pnl_variance_ema, sigma_threshold, welford_count
- kelly_eps_recovery: factor, live, min, max, n_recovery
- popart_envelope: max_abs_reward_ema, decay_alpha
- iqn_tau_boost: factor, n_window

All values read directly from ISV slots (drift-free per spec issue #9).
F3 will add the 'transition' group when v9 slot renames land in the diag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 12:17:13 +02:00
jgrusewski
baf971ba54 diag(rl): emit v9 eval_warmup state to JSONL + cleanup lints
Diag: surfaces `risk_stack.eval_warmup.{remaining,active,blend,
floor_*,target_*}` so the v9 defensive-warmup window is observable
in diag.jsonl. `remaining` is the counter; `blend` is the
defensive-vs-normal mix coefficient (1.0 = full defensive, 0.0 =
normal); `floor_*` reflect the LIVE override values (read AFTER the
warmup kernel ran). Pre-warmup the kernel is a no-op (remaining=-1),
so v9 train-phase diag is bit-identical to v8.

Cleanup: tightens unreachable_pub items in tests/behavioral/* and
tests/sp5_producer_unit_tests.rs (pub → pub(crate)), removes
unused_mut on 6 sp5 scratch buffers, renames unused `step` loop
counter in alpha_baseline example, and explicitly discards an
intentionally-no-op `Command::assert` in cli_integration_test.
Reduces lint count by ~25; remaining 3 dead_code warnings flag
SP15 Phase 2A behavioral scaffolding (Phase 2B never landed —
deliberate signal, not noise).

Pre-existing pearl per feedback_no_hiding: do NOT suppress these
with #[allow]; the warnings ARE the design call surface.
2026-05-31 02:12:04 +02:00
jgrusewski
ad5b29e652 diag(rl): emit atom-span calibration signals (no behavior change)
Per docs/superpowers/specs/2026-05-31-c51-atom-span-math-validation.md,
the binding constraint on atom_max during training is the dynamic
Bellman bound `atom_max ≥ WIN + γ × atom_max`, not the overstated
fixed-point `WIN/(1-γ)`. The math validation against local b=128
smoke confirmed atom_max can be 4.5× the fixed-point bound yet train
cleanly (qpa=+0.969 at step 999).

This commit adds derived diag fields under
`risk_stack.atom_calibration` to expose the bound directly:

  - win_bound, atom_max, gamma         (inputs)
  - dynamic_bound = WIN + γ × atom_max (the binding constraint)
  - atom_max_headroom (=atom_max - dynamic_bound; >0 = self-consistent)
  - popart_sigma, v_target_max_3sigma  (statistical V_target estimate)
  - atom_max_over_3sigma                (resolution waste ratio)

These let future cluster runs measure CURRENT design's over-sizing
empirically (smoke step 999 showed atom_max ~5× larger than 3σ of
V_target requires — wasteful but safe). Future iterations can use
this data to safely tighten atom_max anchor toward V_target_max
without speculating about which design works.

Pure additive diag — no kernel changes, no behavior change. Pulls
values from existing ISV slots (RL_REWARD_CLAMP_WIN_INDEX,
RL_C51_V_MAX_INDEX, RL_GAMMA_INDEX, RL_POPART_SIGMA_INDEX).

Validates the math from 2026-05-31-c51-atom-span-math-validation.md
empirically in every cluster run going forward.
2026-05-31 01:20:37 +02:00
jgrusewski
5e4c2e62b6 fix(rl): CMDP DD recovery + IQN τ reads mean-of-active (not worst)
Diagnosed via the diag-emit added in 6e0f56816 — alpha-rl-d6d8d step 2000
showed worst-account at -$18k, IQN τ pinned at floor 0.1, popart σ
collapsing 1.00 → 0.47. Root cause: dead-account accumulation in a
sticky-DD fleet.

The per-batch CMDP from 39efacf77 fixed the GLOBAL lockout but left
two failure modes:

  1. Once an account hit `dd_limit`, `session_dd_triggered_per_batch[b]`
     stayed sticky for the fold. `actions_to_market_targets` forced
     those accounts to no-op → V_target ≈ γ·V(s'_unchanged) → popart
     σ leaked variance from accumulating dead-weight.

  2. ISV[RL_SESSION_PNL_USD_INDEX] exposed worst-account pnl, which
     IQN-τ consumes for `drawdown_frac`. A single broken account
     dragged τ to its floor for the entire fold — defensive action
     selection for a fleet that was, on average, doing fine.

Fix A: DD recovery on cooldown expiry. When `dd_limit` trips, also
start a recovery cooldown clock. When the clock decrements to 0,
clear `dd_triggered` + reset `session_pnl` to 0. Account rejoins the
active fleet. Matches surfer trading philosophy: accept the wipeout,
take the forced break, get back on the board.

Fix B: IQN-τ signal split. CMDP now writes mean-of-active-accounts
to RL_SESSION_PNL_USD_INDEX (slot 662, IQN-τ consumer) and mirrors
worst per-batch pnl to a new slot RL_SESSION_PNL_WORST_INDEX (684,
diag only). τ now tracks fleet-typical drawdown instead of a single
catastrophic outlier.

Subtle kernel detail caught by G1: `cool_prev` must be snapshotted
BEFORE the DD-trip section, otherwise a fresh cooldown set this step
gets immediately decremented by 1 in the same launch.

Validation (local b=128 1k smoke):
  metric            | pre-fix (HEAD) | with A+B
  worst pnl          | -$34,016       | -$7,183     (79% less bleed)
  mean active pnl    | n/a            | +$40,343    (typical account profitable)
  IQN τ              | 0.10 (floored) | 0.50        (neutral)
  popart σ           | 0.47→collapsing| 0.84        (variance preserved)
  win rate           | 0.21           | 0.56        (positive edge)

13/13 risk_stack_invariants pass (G1 updated, G1b NEW for cooldown
recovery). 20/20 trade_management_kernels pass. integrated_trainer_smoke
end-to-end passes.

Slot 684 added: RL_SESSION_PNL_WORST_INDEX. RL_SLOTS_END 684 → 685.
2026-05-30 22:40:36 +02:00
jgrusewski
6e0f568160 diag(rl): emit risk-stack ISVs to JSONL
Added `risk_stack` section to alpha_rl_train.rs diag emitter so the five
risk-management layers introduced in 285d42aa7 are observable from the
diag JSONL. Without this the kernels run but the output is invisible.

  cmdp           session_pnl_usd, session_dd_triggered, consec_loss_*,
                 cooldown_*, max_open_units, net_inventory_limit_usd
  iqn_tau        action_tau, tau_min, dd_sensitivity
  inventory      penalty_beta, variance_ema
  kelly          fraction, win_rate_ema, avg_win/loss_usd_ema,
                 safety_frac, min_trades_for_release, cumulative_dones
  trail_factors  tighten, loosen

Pure additive emission — no kernel changes, no perturbation of training
state. Local 5-step smoke b=16 confirms every field appears with values
that match the spec math (e.g. IQN τ adapts linearly with drawdown).

Doesn't affect the in-flight alpha-rl-2bm59 (pinned SHA 285d42aa7);
takes effect on the next submission.
2026-05-30 21:24:22 +02:00
jgrusewski
285d42aa7b feat(rl): adaptive risk-management stack — 5 layers, all ISV-driven
Layered risk stack per spec docs/superpowers/specs/2026-05-30-adaptive-risk-management-design.md:

  Layer 1 (CMDP)        hard session-DD, cooldown, max-open, inventory limits
  Layer 2 (IQN τ)       risk-averse action selection adapts to session drawdown
  Layer 3 (Inventory)   Avellaneda-Stoikov penalty β scales with reward magnitude
                        and inventory variance
  Layer 4 (Kelly)       half-Kelly fraction sizing from observed win-rate +
                        R-multiple, warmup-gated until cumulative_dones >= 1000
  Layer D (Trail)       wire dead a7/a8 (TrailTighten/Loosen) via independent
                        ISV factors, replacing the symmetric reciprocal

Architecture: every threshold ISV-driven (22 new slots, RL_SLOTS_END 662→684).
Every adaptive bound follows the canonical Wiener-α blend with floor 0.4,
sentinel-zero bootstrap, and asymmetric Schulman where applicable.

Kernels:
  rl_cmdp_constraints_check        session pnl + cooldown + consec-loss tracking
  rl_iqn_action_tau_controller     τ = clamp(0.5 - 5·dd_frac, τ_min, 1.0)
  rl_inventory_beta_controller     β_target = 0.01·E|reward| / (2·σ_inventory)
  rl_kelly_fraction_controller     f = clamp(safety · (p·b - q)/b, 0, 1)
  rl_win_rate_ema_update           closed-trade win-rate EMA from rewards + dones
  rl_avg_win_loss_ema_update       separate avg-win and avg-loss EMAs
  rl_inventory_variance_update     Welford variance of net-position-per-batch

Integration:
- All 7 new cubins loaded in IntegratedTrainer + launched per step in spec order
  (CMDP after reward pipeline, before actions_to_market_targets reads override
  flags; Layer 2/3/4 controllers in rl_fused_controllers.cu).
- actions_to_market_targets.cu: Layer 1 hard overrides (DD-triggered → 0 lots;
  cooldown → 0 lots; max-open → block opening actions; inventory cap → block
  one-sided expansion) and Layer 4 Kelly fraction scaling on target lots.
- rl_fused_reward_pipeline.cu: Layer 3 inventory penalty term in reward shaping.
- rl_trail_mutate.cu: a7 multiplies trail by RL_TRAIL_TIGHTEN_FACTOR_INDEX,
  a8 by RL_TRAIL_LOOSEN_FACTOR_INDEX (was symmetric reciprocal — pearls
  pearl_dead_trail_stop_actions_a7_a8).

Validation:
- 12 GPU-oracle invariants pass (tests/risk_stack_invariants.rs):
  G1-G4 CMDP, G5-G7 IQN τ, G8-G9 inventory β, G10-G12 Kelly.
- 20/20 trade_management_kernels.rs tests pass (a7/a8 migrated).
- 5/5 controller_adaptive_floors.rs tests still pass.
- integrated_trainer_smoke passes (end-to-end pipeline launch).
- Local 1k smoke b=128: completes 1000/1000 steps, no NaN, controllers steady.
- compute-sanitizer memcheck (5 steps b=128): ERROR SUMMARY: 0 errors.

Plan: docs/superpowers/plans/2026-05-30-adaptive-risk-management-plan.md
2026-05-30 20:52:28 +02:00
jgrusewski
69d8038a80 fix(cuda): SAC co-tuning reads ACTION entropy, not policy entropy
Root cause: confidence gate decouples policy from actions. Policy
softmax stays high-entropy (~2.4) while the gate forces Hold, producing
action entropy ~0.7. SAC read policy entropy EMA (slot 420), saw
"above target", and kept LOWERING τ — the exact opposite of what was
needed.

New kernel `action_entropy_per_step` computes H(action_histogram) from
the POST-gate actions buffer and writes to ISV slot 583
(RL_ACTION_ENTROPY_EMA_INDEX). SAC co-tuning in rl_q_pi_distill_grad
now reads this slot. Launched OUTSIDE CUDA Graph capture (after
confidence gate + FRD gate) in both training and prefill paths.

Kernel design: 11 threads (N_ACTIONS), each thread counts its action
across all b_size elements. Thread 0 computes entropy from the
histogram and updates the EMA. No atomics.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:44:10 +02:00
jgrusewski
cfc89313bb fix(cuda): SAC co-tuning uses batch-average entropy + asymmetric rates
Two bugs caused τ to collapse to floor (1.0) instead of ramping up:

1. Single-sample entropy: SAC auto-tune computed s_entropy from block 0
   only (one batch element). At b=1024 this is noise. Now reads
   RL_ENTROPY_OBSERVED_EMA_INDEX (slot 420) — the batch-average entropy
   EMA written by ema_update_per_step via tree reduction.

2. Symmetric step rate: SAC_ALPHA_STEP=0.001 was identical for ramp and
   decay. Entropy collapsed faster than the controller could recover.
   Now asymmetric: SAC_STEP_RAMP=0.01 (100× faster when entropy is below
   target) vs SAC_STEP_DECAY=0.0001. Per pearl_asymmetric_controller_decay.

Also adds sac_alpha + sac_entropy_target to JSONL isv_config for
diagnostic visibility.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:29:57 +02:00
jgrusewski
0e70bf96fe fix(rl): wire BCE + aux losses into GPU loader path + event-based sync
BCE and aux losses were hardcoded to 0 in the GPU data path. Now
step_batched_from_device returns (bce_loss, aux_loss), stored as
last_bce_loss / last_aux_loss and fed into step stats + JSONL.

Event-based sync replaces host-blocking stream sync:
- perception: raw_event_record after training graph, raw_event_sync
  in read_deferred_stats (instant — graph completed long ago)
- diag_staging: raw_event_record after snapshot_async, raw_event_sync
  in sync_and_swap (instant — copies completed during GPU work)

All JSONL fields preserved. Loss values one-step deferred (same as
before for Q/π/V, now also for BCE/aux).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 22:18:30 +02:00
jgrusewski
1f672c3b09 perf(rl): GPU-resident data loader — zero CPU per step
Upload all pre-converted snapshots + FRD labels to GPU at init (1.2GB
for 5.6M snapshots / 2 files locally, ~11GB for 45M / 9 files on L40S).

New GPU kernels: gpu_sample_and_gather (PRNG sampling + AoS-to-SoA),
gpu_gather_next/gpu_gather_current (anchor offset re-gather),
gpu_gather_frd_labels (per-horizon label gather).

step_with_lobsim_gpu: GPU-only data path replacing host-side loader.
Encoder forwards via forward_encoder_from_device. Eliminates 7700 heap
allocs + 418k scalar copies + 16ms CPU work per step at b=256.

Init uploads use cuMemcpyHtoD_v2 (synchronous, one-time).

Note: apply_snapshot skipped (lobsim book stale, dones/rewards=0).
Follow-up: copy last-snapshot book data from SoA to lobsim buffers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:51:06 +02:00
jgrusewski
a429c3f901 perf(rl): move diag sync_and_swap after step — overlap with GPU compute
diag_staging.sync_and_swap() was at the START of the step loop,
blocking host for ~5.5ms while GPU was idle. Moved to AFTER
step_with_lobsim (which ends with end-of-step sync that guarantees
previous diag copies completed). Eliminates one of the two per-step
host-blocking syncs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 19:58:07 +02:00
jgrusewski
6eedfea593 perf(rl): async double-buffered data prefetch — overlap loader with GPU
Background thread runs loader.next_sequence_pair() × n_batch while
GPU trains on the current batch. sync_channel(2) double-buffer.
Eliminates loader latency from the critical path at b=256 where
256 sequential next_sequence_pair calls took ~19ms/step (the entire
step budget at 52 sps).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 19:39:28 +02:00
jgrusewski
a9c417a79e perf(rl): replace ALL cudarc memcpy/sync with raw CUDA API in hot path
15 diag staging DtoD + 1 FRD labels DtoD + diag sync all converted
to raw_memcpy_dtod_async / raw_stream_sync. Zero cudarc driver calls
in steady-state step loop.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 14:20:30 +02:00
jgrusewski
b531bd7148 perf(rl): mapped-pinned ISV + loss — zero DtoD copies forever
Replace isv_d CudaSlice with isv_mapped MappedF32Buffer. GPU writes
via dev_ptr, host reads via host_ptr. Zero copies, zero syncs.
Same for 4 loss accumulators (q/pi/v/frd).

Delete: isv_host Vec, isv_staging, loss_staging, loss_dtod_done_event,
all DtoD copies for ISV/losses, all event record/sync for losses.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 13:27:39 +02:00
jgrusewski
1d167af7b0 perf(diag): migrate snapshot_async to raw ptrs — 15 fewer device_ptr() per step
Replace 15 CudaSlice device_ptr() calls (each triggers cudarc event
tracking) with direct raw_ptr() u64 reads. Zero CUDA driver overhead
in the diag staging path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:20:02 +02:00
jgrusewski
63e447a182 feat(rl): outcome head backward + diag JSONL enrichment
Wire outcome CE backward → grad_W/b → Adam → grad_h_accumulate.
Add PopArt, spectral, Q-bias, per-branch LR, outcome metrics to
diag JSONL for training validation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:47:19 +02:00
jgrusewski
09873de1f5 perf(rl): zero-HtoD FRD labels via mapped-pinned staging + make stream pub
Replace write_slice_i32_d_pub (sync + alloc + DtoD) with persistent
MappedI32Buffer staging. Host writes to host_ptr, async DtoD to
frd_labels_d on the train stream — zero sync, zero alloc per step.

Also: make stream field pub for external DtoD access.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 01:21:07 +02:00
jgrusewski
5f3e3578a0 perf(diag): move ALL remaining device reads to async staging — zero syncs
Expanded DiagStaging with 8 more fields (position_lots, pyramid_count,
unit_entry_price, unit_entry_step, unit_lots, unit_trail,
close_unit_index, frd_logits). Eliminates 36 stream syncs per step
that consumed 90.9% of CUDA API time per nsys profile. unit_active
derived from unit_lots != 0 on host (avoids u8-to-f32 DtoD mismatch).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 01:11:59 +02:00
jgrusewski
1ddf74fb51 fix(diag): replace hardcoded replay_len=0 with actual GPU PER capacity
The old host replay.len() was replaced with 0usize during T1 cleanup.
GPU PER is working fine — just not reporting its length in the diag.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 00:58:48 +02:00
jgrusewski
ee2cc147f6 feat(diag): async non-blocking staging via separate stream + double-buffer
DiagStaging runs DtoD copies on a separate CUDA stream — zero stalls
on the training pipeline. Double-buffered mapped-pinned: host reads
previous step's data while current step's copies run concurrently.

Replaces 7 blocking read_slice_d calls (ISV, rewards, dones, actions,
raw_rewards, trade_duration, outcome_ema) with async staging reads.
One-step delay on diag data (acceptable for diagnostics).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 00:48:00 +02:00
jgrusewski
ef23dfb3d9 refactor(rl): delete host-side PER + diag-every band-aid (greenfield prep)
Remove: ReplayBuffer, Transition, NStepEntry, push_to_replay,
sample_and_gather, n_step_buffer, replay.rs, --diag-every flag.
PER call sites stubbed with todo!() — replaced by GPU PER in next commits.

Also fixes pre-commit hook to allow todo!() macro (per CLAUDE.md:
"todo!() macro is OK for runtime stubs") while still blocking
TODO/FIXME comment markers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 00:18:11 +02:00
jgrusewski
3541ba7352 perf(diag): add --diag-every flag + fix PnL accumulation bug
- --diag-every N (default 100): skip device readback on non-diag steps.
  Eliminates ~10 stream.sync per step → ~2× throughput at large batch.
- Fix PnL bug: was accumulating raw_rewards (shaped, all batches) instead
  of rewards/scale on done steps only.
- Wire diag-every=100 in Argo template (diag JSONL every 100 steps).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 23:56:21 +02:00
jgrusewski
7b983e5c4f feat(diag): add per-trade PnL, win rate, hold time, sps to JSONL + log
New diag fields in the "trading" object:
- pnl_cum_usd: cumulative realized PnL in USD (shaped, pre-scale)
- total_trades: lifetime trade count
- win_rate: fraction of positive-PnL closes
- avg_hold_steps: mean hold duration in steps
- raw_reward_sum: per-step sum of shaped rewards

Log line now shows sps (steps/sec), pnl (cumulative $), wr (win rate).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 23:45:47 +02:00
jgrusewski
fcb8222a60 feat(rl): tier 2 wave-scale — γ=0.995, PER 32k, min-hold 100
Prepared for next run after the γ=0.99 1M run completes:

- γ floor 0.99 → 0.995: horizon 100 → 200 steps (50 seconds).
  Real ES directional moves (2-5 points) happen at this scale.
- PER 16384 → 32768: 2048 unique steps of replay depth. Supports
  the 200-step γ horizon with margin.
- Min-hold 50 → 100 steps (25 seconds): commit to the full wave.
  Short-hold penalty threshold matches.

Safe to push because raw-reward re-normalization eliminates scale
drift in the deeper buffer, and the ±2% scale clamp keeps targets
stable across the 2048-step replay window.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 15:26:58 +02:00
jgrusewski
0251656be4 fix(rl): replay reward re-normalization eliminates scale drift
Stored raw_reward alongside scaled reward in Transition. At sample
time, consumer re-applies current reward_scale to raw_reward instead
of using the stale-scale stored reward. This eliminates off-policy
scale drift that caused q_pi_agree to collapse from 0.125 to 1e-22
over 40k steps with PER=32768.

PER capacity set to 16384 (1024 unique steps at b=16) — balances
replay depth against h_t representation staleness.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:44:32 +02:00
jgrusewski
5cb34572eb fix(rl): CLI per_capacity default 4096 → 32768 to match trainer config
The CLI default overrode the trainer config default. Without passing
--per-capacity explicitly, the Argo run used 4096 (256 unique steps
at b=16) instead of the intended 32768 (2048 steps).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:24:27 +02:00
jgrusewski
0e15899670 feat(rl): exhaustive diag JSONL for all trade-management mechanics
Surfaces full per-unit per-batch state in the per-step diag output:

- units: entry_price, entry_step, lots, trail_distance, active_mask,
  unit_count (all [B × MAX_UNITS] arrays)
- trail: fired/tightened/loosened counts (step + cumulative)
- pyramid: added count (step + cumulative), units_distribution,
  max_units_reached flag
- partial_flat: fired count (step + cumulative), long/short split,
  close_unit_index per batch
- confidence_gate: gated count (step + cumulative)
- frd_gate: gated count (step + cumulative)
- position_heat: capped count (step + cumulative), max_lots ISV
- anti_martingale: per-batch outcome_ema, kappa ISV

Replaces the minimal pyramid/heat_cap diag from P7. Every mechanic
is now fully observable in post-hoc analysis.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 22:05:24 +02:00
jgrusewski
a583bb508c feat(rl): pyramiding semantics — add/partial-flat/anti-martingale sizing
Implements the full pyramid trade-management suite:

P7.a: actions_to_market_targets gates pyramid adds on ISV-driven
      threshold (slot 506); rl_unit_state_update allocates sequential
      unit slots on position growth, deactivates oldest on shrink.

P7.b: HalfFlat (a9/a10) closes oldest unit's lots when pyramid>1;
      trail-stop routes breaches through HalfFlat + close_unit_index
      override instead of nuclear full-flat.

P7.c: Anti-martingale sizing on opening actions via signed outcome EMA
      (slot 508) — size_eff = base × clamp(1 + κ × ema, MIN, MAX).

Diag: pyramid { units_count, add_count, outcome_ema } in step JSONL.

ISV slots: 506 threshold, 507 add_count, 508 outcome_ema,
           509 κ, 510 MIN, 511 MAX. RL_SLOTS_END → 512.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 21:15:04 +02:00
jgrusewski
45a2041db4 feat(rl): SP20 P6 position heat cap — force-flat on over-leverage
Last-defense guard: if |position_lots| exceeds the ISV-driven
RL_HEAT_CAP_MAX_LOTS (slot 504, default 8 = MAX_UNITS × max_order_size),
the kernel overrides actions[b] to FlatFromLong (a3) or FlatFromShort
(a4) — full flatten, no partial. Catches runaway pyramid accumulation
before it reaches actions_to_market_targets.

Override stack ordering (step_with_lobsim):
  1. rl_trail_mutate (a7/a8)
  2. rl_trail_stop_check → may override to FlatFromLong/Short
  3. rl_position_heat_check (THIS) → may override to FlatFromLong/Short
  4. actions_to_market_targets → reads final actions[b]

Kernel `cuda/rl_position_heat_check.cu`:
  * 1 block, b_size threads (grid-stride for b_size > 256)
  * Reads position_lots from pos_state at offset 0 (PosFlat layout)
  * Cap read from ISV[504]; if cap ≤ 0 → no-op (guard disabled)
  * Per feedback_no_atomicadd: fired-count diagnostic uses shared-mem
    flag array + thread-0 serial count (b_size ≤ 256 in practice)
  * Writes fired-count to ISV[505] for diag

ISV slots:
  * 504: RL_HEAT_CAP_MAX_LOTS_INDEX (seed 8.0)
  * 505: RL_HEAT_CAP_FIRED_COUNT_INDEX (diagnostic, written per step)
  * RL_SLOTS_END bumped 505 → 506

Diag (alpha_rl_train):
  * "heat_cap": { "fired_count": N, "max_lots": 8 }

GPU oracle test (trade_management_kernels.rs):
  * position_heat_cap_overrides_on_breach — long 5 > cap 4 → a3;
    short -5 < -cap -4 → a4; long 3 ≤ cap 4 → untouched (Hold)

Verification (RTX 3050 Ti):
  * cargo check -p ml-alpha --examples → clean
  * integrated_trainer_smoke 1/1 → ok
  * trade_management_kernels 6/6 (was 5/5, +1 heat cap) → ok
  * audit-rust-consts → 0 flags
2026-05-24 20:36:05 +02:00
jgrusewski
7df7c81d37 refactor(rl): FRD horizons + range_σ are ISV-driven, not literals
Closes the literal/const drift gap that F.5 introduced. Per
feedback_isv_for_adaptive_bounds + feedback_single_source_of_truth_no_duplicates:
adaptive bounds belong in ISV (or in a single canonical const that
ISV references), never duplicated as literals across modules.

Single canonical source: `crate::rl::common::FRD_HORIZON_TICKS` +
`FRD_BUCKET_RANGE_SIGMA` (already declared in F.5).

Producer-side fixes:
  * Trainer ISV bootstrap (integrated.rs): the seed values for slots
    500-503 now dereference the canonical consts instead of hardcoded
    60.0/300.0/1800.0/3.0 literals. Future tuning of the consts
    automatically propagates to both ISV seeds and loader-side
    labels — no manual sync required, no drift possible.
  * compute_frd_labels (loader.rs): takes `horizon_ticks` and
    `range_sigma` as parameters instead of reading consts directly.
    Caller (the file-load closure) sources them from the new
    MultiHorizonLoaderConfig fields.

Consumer-side fixes — 8 MultiHorizonLoaderConfig literal sites now
provide the two new fields, all defaulting to the canonical consts:
  * crates/ml-alpha/src/data/loader.rs (2 internal test-fixture sites)
  * crates/ml-alpha/tests/multi_horizon_loader.rs (2 sites)
  * crates/ml-alpha/examples/alpha_train.rs (2 sites)
  * crates/ml-alpha/examples/alpha_rl_train.rs (2 sites)
  * crates/ml-backtesting/src/harness.rs (1 site)
  * crates/ml-backtesting/tests/{trainer_parity,ring3_replay}.rs (2 sites)

The "optimal by default" property is preserved: every caller that
doesn't explicitly override gets the spec-recommended 60/300/1800
ticks + ±3σ. Callers that need to retune set the config fields, and
the trainer's ISV slots provide a runtime knob for the same numerics.

Verification (RTX 3050 Ti):
  * cargo check -p ml-alpha -p ml-backtesting --examples --tests → clean
  * cargo test --lib (6/6 unit tests for FRD label gen + loss_balance) → pass
  * frd_head 10/10 + integrated_trainer_smoke 1/1 + trade_mgmt 5/5 → pass
  * audit-rust-consts → 0 flags

The two new MultiHorizonLoaderConfig fields are required (no Default
impl) — callers MUST opt in to the FRD label-generation contract by
naming the fields. This is the same discipline applied across other
config consumers; making them Option<...> would silently default to
"no FRD labels" and break F.4's expected supervised signal.
2026-05-24 19:23:39 +02:00
jgrusewski
125c667a34 feat(rl): FRD label generation in loader + per-step write (F.5)
Activates the FRD head's supervised training signal that F.4 wired
through the trainer. Per-file forward-return σ-bucketed labels
computed at load time + per-step write into trainer.frd_labels_d
before each step_with_lobsim.

Loader-side label generation (`compute_frd_labels` in data/loader.rs):
  * Mid-price series from snapshots[i].levels[0]
  * Per-file σ_per_step = sample-std of single-tick mid increments
  * For each FRD_HORIZON h ∈ {60, 300, 1800}:
    - r = (mid[i+h] - mid[i]) / (σ_per_step × sqrt(h))   ← Brownian scaling
    - bucket = round(r × (FRD_N_ATOMS-1) / (2 × FRD_BUCKET_RANGE_SIGMA)
                     + (FRD_N_ATOMS-1) / 2)
    - clamp to [0, FRD_N_ATOMS-1] for tail returns
    - sentinel -1 if i + h >= n
  * Cached in LoadedFile.frd_labels_full alongside sigma_k_full /
    outcome_*_full
  * Per-anchor slice into LabeledSequence.frd_labels (length-1 vec
    per horizon at the newest-snapshot index — h_t aligns with the
    rightmost K position, the only one the FRD head supervises)

New structural constants in rl/common.rs:
  * FRD_HORIZON_TICKS = [60, 300, 1800]  ← matches ISV slots 500/501/502 defaults
  * FRD_BUCKET_RANGE_SIGMA = 3.0          ← matches ISV slot 503 default
  Per pearl_glm_fitter_link_must_match_inference: bucket-edge math
  here MUST match the trainer-side softmax+CE atom interpretation.
  Both reference the same const so they can't drift.

alpha_rl_train per-step wiring:
  * Stage frd_labels_bh[b_idx × FRD_N_HORIZONS + h] from
    s_t.frd_labels[h][0] (the per-batch label at this step's anchor)
  * write_slice_i32_d_pub into trainer.frd_labels_d BEFORE
    step_with_lobsim → bwd chain reads real labels in step_synthetic

Tests (3 new in loader::frd_label_tests, total 3/3 passing):
  * frd_labels_flat_price_maps_to_mid_bucket — constant mid → all
    non-sentinel labels = 10 (FRD_N_ATOMS/2 rounded); sentinel range
    [n-h, n) tested exhaustively
  * frd_labels_monotonic_ramp_lands_in_upper_buckets — linear ramp
    mid[i] = 100 + 0.01×i produces forward returns way above 3σ at
    every horizon → clamp to top bucket (FRD_N_ATOMS-1=20)
  * frd_labels_short_input_below_h_ticks_all_sentinel — n=10 < h_ticks
    for all 3 horizons → every label is -1 (no leak in the sentinel path)

Existing tests still pass:
  * loss_balance lib tests 3/3
  * frd_head GPU tests 10/10
  * integrated_trainer_smoke 1/1
  * trade_management_kernels 5/5

The full FRD head pipeline is now active end-to-end. Cluster smoke
will show FRD entropy_mean drift below ln(21) ≈ 3.044 once the bwd
gradient signal accumulates — the observable proof that supervised
learning is happening. The "frd" diag block from F.2 was always
prepared for this; F.5 just feeds it real signal.

F.6+ scope (deferred, separate sessions):
  * P9 FRD gate — override action to Hold when entry_quality < THR
  * Loss-balance controller integration for λ_frd (currently 1.0 default)
  * Per-horizon Sharpe attribution in diag
2026-05-24 19:15:40 +02:00
jgrusewski
935433850c feat(rl): FRD head trainer integration — Adam + bwd chain + loss (F.4)
Wires the F.3a/b/c backward kernels into IntegratedTrainer's per-step
flow so the FRD head trains end-to-end as a 6th loss-balanced head
alongside BCE/Q/π/V/aux. With labels currently sentinel-initialized to
-1 (F.5 loader will populate from forward-snapshot lookahead), the
chain produces zero gradients + zero loss — Adam steps are no-ops
modulo β decay, and the encoder receives no FRD-derived signal yet.
The wiring is complete and the path is exercised end-to-end; F.5 just
needs to swap the labels in for the head to start training.

IntegratedTrainer state additions:
  * frd_w1_adam / frd_b1_adam / frd_w2_adam / frd_b2_adam — AdamW
    instances for the 4 FRD weight tensors (LR mirrored per-step from
    ISV[RL_FRD_LR_INDEX=499], seed 1e-3 per F.1).
  * frd_labels_d — owned [B × FRD_N_HORIZONS] i32 buffer, sentinel-
    initialized to -1 (every entry "missing horizon" → softmax_ce_grad
    zeros loss + grad for every row). F.5 loader integration overwrites
    pre-step from forward-return-bucketed labels.

LossLambdas extension:
  * Added `frd: f32` field, default 1.0
  * read_loss_lambdas_from_isv reads slot 498 (RL_FRD_LAMBDA_INDEX)
    with the standard zero-sentinel bootstrap path
  * Doc-comment updated: "5 heads / 5.0" → "6 heads / 6.0"

IntegratedStepStats extension:
  * Added `l_frd: f32` — mean CE across (B × FRD_N_HORIZONS) rows
  * step_synthetic returns the real l_frd from the bwd chain; the
    new combined l_total formula includes `lambdas.frd × l_frd / 6`

step_synthetic bwd chain — inserted between Step 9 (Q/π/V Adam) and
Step 10 (grad_h_t_combined zero+accumulate):
  1. softmax_ce_grad → frd_grad_logits_d + frd_loss_per_b_h_d
  2. layer2_bwd → frd_grad_w2_pb_d, frd_grad_b2_pb_d, frd_grad_hidden_d
  3. layer1_bwd → frd_grad_w1_pb_d, frd_grad_b1_pb_d, frd_grad_h_t_d
  4. 4× reduce_axis0 to collapse per-batch scratch → final grads
  5. 4× AdamW.step on w1/b1/w2/b2
  6. read loss_per_b_h via mapped-pinned, average → l_frd_host

Step 10 grad_h_t_combined accumulation adds a third λ-weighted call:
  accumulate_grad_h(frd_grad_h_t_d, lambdas.frd, &mut combined)

With sentinel labels (F.4 state) this contributes zero gradient to the
encoder backward — the wiring is exercised but silent. F.5 makes it
active by providing real labels.

alpha_rl_train diag JSON gains:
  * "loss": { ..., "frd": stats.l_frd, ... }
  * "lambdas": { ..., "frd": stats.lambdas.frd, ... }

Verification (RTX 3050 Ti):
  * cargo check -p ml-alpha + --examples → clean
  * integrated_trainer_step_with_lobsim_runs_without_panic → ok
    (l_total 0.5073 vs prior 0.6087 — ÷6 instead of ÷5 expected;
    l_frd=0 confirms sentinel labels are passing through cleanly)
  * frd_head 10/10 tests still pass (no regression)
  * trade_management_kernels 5/5 → no regression
  * audit-rust-consts → 0 flags

F.5 (next, separate scope):
  * Loader-side forward-return label generation (mid[i+h] - mid[i])/σ
    bucketed into FRD_N_ATOMS=21 atoms over the ISV-driven ±range_σ
  * Populate trainer.frd_labels_d before each step_with_lobsim call
  * That unlocks the supervised learning signal; FRD entropy_mean
    should start dropping below ln(21) in diag as the head trains.
2026-05-24 19:03:20 +02:00
jgrusewski
119c3a15f4 feat(rl): wire FRD head forward into trainer + diag (F.2 integration)
IntegratedTrainer now owns an FrdHead instance and per-step buffers
(frd_hidden_d [B × FRD_HIDDEN_DIM=64], frd_logits_d [B × FRD_OUT_DIM=63]).
The forward kernel runs in step_with_lobsim immediately after the
current-snapshot encoder forward, reading h_t_borrow and producing the
3-horizon × 21-atom return-bucket logits.

step_with_lobsim FRD forward placement rationale: it has to read
self.perception.h_t_view() AFTER the second forward_encoder(snapshots)
call (which lands h_t at slot K-1), but BEFORE any downstream
consumer of the encoder state — so right between Step 1b and Step 2.
This keeps the FRD output aligned with the same h_t that the Q / π /
V heads see for action sampling.

alpha_rl_train diag emits a new "frd" block per step:
  "frd": { "h1": {"entropy_mean", "argmax_mean"}, "h2": ..., "h3": ... }

At init (Xavier × 0.1, b1=b2=0) the per-horizon softmax is near-
uniform → entropy_mean ≈ ln(21) = 3.044 and argmax_mean drifts around
the uniform expectation of 10. As supervised training kicks in (F.3),
entropy drops and argmax tracks the realized forward-return mode per
horizon — this is the observable signal that lets us catch a broken
backward kernel before cluster smoke.

Verification:
  * cargo check -p ml-alpha --examples → clean
  * integrated_trainer_step_with_lobsim_runs_without_panic → ok
    (1.66s, b_size=1, full step path through encoder + FRD + Q/π/V)
  * audit-rust-consts → 0 flags
  * trade_management_kernels (5/5) + frd_head (3/3) → still pass

F.3 (backward kernel + finite-diff tests + label generation in loader
+ λ_frd-weighted loss accumulation into stats.l_total) is the next
chunk. FRD-gate (P9) and FRD label-cache wiring are separate scope.
2026-05-24 18:22:44 +02:00
jgrusewski
d3175711b9 feat(rl): SP20 P4 — N_ACTIONS 9→11 with HalfFlat actions
Action enum extended:
  a9  = HalfFlatLong   (close ⌈|pos|/2⌉ of long position, no-op if not long)
  a10 = HalfFlatShort  (close ⌈|pos|/2⌉ of short position, no-op if not short)

`actions_to_market_targets.cu` extended with a9/a10 handlers:
  HalfFlatLong (pos > 0):  side=1 sell, size=max(1, (position_lots+1)/2)
  HalfFlatShort (pos < 0): side=0 buy,  size=max(1, (|position_lots|+1)/2)

Round-up division ensures min 1 lot closes — single-lot positions
fully close on HalfFlat (the half rounds up to 1).

N_ACTIONS=9 → 11 propagated to all 10 .cu kernels:
  argmax_expected_q, bellman_target_projection, dqn_distributional_q,
  log_pi_at_action, ppo_clipped_surrogate, rl_action_kernel,
  rl_entropy_coef_controller, rl_pi_action_kernel, rl_q_pi_agree_b,
  rl_q_pi_distill_grad

Rust-side N_ACTIONS const bumped to 11 in src/rl/common.rs.

CLI alpha_rl_train.rs action_hist + windowed_act_hist refactored
to reference `N_ACTIONS` const instead of literal 11. Caught DURING
this commit's dogfood: an intermediate state had `[0u32; 11]` but
left `(0..9).contains(&a)` unchanged — HalfFlat samples silently
dropped (a9/a10 showed 0% in diag despite P_MIN=0.02 floor
guaranteeing 2% each). Fix uses N_ACTIONS const everywhere; new
pearl `feedback_use_consts_not_literals_for_structural_dims`
codifies the meta-pattern (Rust code mirroring kernel structural
dims must reference the const, NEVER duplicate the literal —
audit-isv only scans .cu files, this class of bug is currently
unaudited in .rs).

Audits PASS:
  audit-isv: all kernel #defines allowlisted (BOOK_LEVELS,
             ACTION_*, structural dims)
  audit-wiring: all 4 actions in manifest (TrailTighten, TrailLoosen,
                HalfFlatLong, HalfFlatShort) have consumers

Local 1k-step smoke (RTX 3050 Ti, 13.5s):
  * Exit 0, 0 NaN/inf, 16000/16000 samples accounted for
  * Action distribution: all 11 used in 7-11% range
  * HalfFL=7.99%, HalfFS=7.51% — π samples them under multinomial
  * Trail=19.34% — agent continues to value trail-stop actions

Trail-stop check (rl_trail_stop_check.cu) currently still routes
force-close through a3/a4 (FlatFromLong/Short) rather than per-unit
partial-flat via a9/a10. That routing upgrade is SP20 P5b follow-up
work — it requires the per-unit close_unit_index buffer wiring per
spec §3 P5. Adding a9/a10 to the action space is the foundational
prerequisite; consumer kernel uses come with P5b.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 17:17:33 +02:00
jgrusewski
e87e0b0774 feat(rl): adaptive λ_distill controller + reward_scale MIN ISV
Two architectural fixes from rljzl in-flight analysis (ultrathink
deep dive on actions a7/a8 + per-action calibration):

(1) λ_distill: static → controller-driven via Schulman bounded step

  wwcsz showed Q→π KL EMA dropped 2.10 → 0.30 with λ=0.01, then
  rljzl bumped to 0.05. Static λ is design intuition; KL is the
  natural feedback signal:
    if KL > target × 1.5 → λ *= 1.2  (Q not landing, pull harder)
    if KL < target / 1.5 → λ /= 1.2  (Q absorbed, relax)
  Bounds [MIN=0.001, MAX=1.0]. Target KL seeded 0.1 (slot 491).
  New kernel `rl_q_distill_lambda_controller.cu`. Runs after the
  distill kernel writes KL_EMA each step.

(2) REWARD_SCALE_MIN: hardcoded 1e-3 → ISV-driven 1e-4

  wwcsz audit (mean_abs_pnl_ema mean=920, max=49437, p99=high):
  the controller wanted scale ≈ 3.5e-4 when EMA spiked to 2871
  but pegged at 1e-3, letting scaled rewards exceed unit support
  and wasting C51 atom resolution on outliers. ISV slot 492
  permits runtime re-tuning; default 1e-4 admits one more order
  of magnitude before pegging. Per user-stated "floors and clamp
  bounds" exemption — ISV-resident for tunability, not because
  required.

Diag exposes q_distill_kl_target + reward_scale_min so the new
adaptation chains are observable.

Investigation (ultrathink): actions 7/8 (TrailTighten/TrailLoosen)
have ZERO consumers across the codebase. Spec'd as "ISV mutation"
in actions_to_market_targets.cu header but no slot, no mutation
kernel, no stop-check kernel. ~10% of wwcsz policy mass goes to
dead no-ops. Documented in
`pearl_dead_trail_stop_actions_a7_a8.md` — implementation
deferred to its own SP (per-batch trail_distance buffer +
mutation kernel + stop-check integration with LobSim apply_fill_to_pos
per `pearl-stop-checks-run-at-deadline-cadence`). N_ACTIONS=9
preserved; alternative refactor to 7 actions captured as
"Path B" in the pearl.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 14:55:55 +02:00
jgrusewski
185add7dc8 feat(rl): adaptive RATIO + EWMA V_MIN/V_MAX + λ_distill bump
wwcsz analysis identified atom-resolution starvation + asymmetric
RATIO mismatch as the empirical ceiling on win rate (38.56% vs
break-even 45.3%). Three coupled fixes shipped in one pass per
"no deferrals":

(a) Adaptive RATIO from observed |loss|/|win| EMAs:
  - apply_reward_scale tracks max(-scaled, 0) per step (slot 489)
  - rl_reward_clamp_controller maintains neg_max_ema (slot 490,
    sparse-aware like pos_max_ema)
  - RATIO = clamp(MIN=1.0, neg_ema/pos_ema, MAX=3.0); writes to
    slot 481
  - Removes built-in 3:1 loss-aversion bias when reality is
    symmetric (wwcsz: actual avg|loss|/avg(win) = 0.83). Floor
    1.0 prevents inverted asymmetry; ceiling 3.0 preserves
    original loss-aversion as the worst case.

(b) C51 V_MAX/V_MIN: ratchet → slow EWMA (α=0.001, half-life ~700
    steps):
  - Static ratchet wasted atom resolution on rare tails — wwcsz
    had V_MIN=-60, V_MAX=20 but realized rewards mostly in [-5, +5]
    (Δz=4 vs typical reward magnitude 1-5)
  - Slow EWMA lets atom span shrink toward active reward range,
    gaining resolution where data lives. Floors at [-1, +1]
    preserve original C51 baseline as the worst case.
  - Slow α gives Q's atom mapping time to be valid across
    encoder/head co-adaptation (vs aggressive EWMA which would
    invalidate Q's learned distribution every step)

(c) Q→π distillation λ bumped 0.01 → 0.05:
  - wwcsz showed KL dropped 2.10 → 0.30 with λ=0.01 — Q signal
    landing but conservatively. Bump tests whether stronger Q
    pull translates to better policy → better R/done.

Diag exposes neg_scaled_max + neg_scaled_max_ema so the RATIO
adaptation chain is observable.

apply_reward_scale shared_mem doubled from 2× to 3× block × f32
to fit the three parallel reductions (abs, pos, neg).

Companion to investigation (e) — n_rollout_steps controller was
suspected of misalignment (256-8192 vs trade_duration ≈ 6 steps)
but turned out to be a K-loop param, not used in Bellman target.
1-step Bellman with γ-bootstrap is the actual mechanism; closed
without code change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 14:20:05 +02:00
jgrusewski
79756a2153 fix(rl): sparse-aware EMA + Q→π distillation breaks defensive trap
Two coupled fixes addressing vj5f6 findings:

(1) WIN_clamp oscillation — sparse-aware EMA

  vj5f6 showed WIN_clamp oscillating 1.0 ↔ 67.0 across 40k steps.
  Root cause: the Wiener-α blend in rl_reward_clamp_controller
  treated pos_max=0 as "no win this step ≡ win magnitude is zero,"
  exponentially decaying the EMA toward 0 during dry-spell windows
  (no closed winning trades). With α=0.4, ten dry steps decayed EMA
  by 0.6^10 ≈ 0.006, collapsing WIN back to MIN_WIN=1.0 floor.

  Fix: only update pos_max_ema AND clip_rate_ema AND MARGIN when
  pos_max > 0. A dry step is "no signal," not "zero signal." The
  EMA retains its last winning-period estimate; the controller
  doesn't ratchet on stale data.

(2) Q→π distillation — couples Q's improved calibration to π

  vj5f6 showed l_q dropping 100× (2.37 → 0.02) but reward economics
  IDENTICAL to 8xwq8 (no C51 V_MAX lift). Per Option B, π drives
  action selection but is trained by PPO surrogate using advantage
  = returns - V. V regression doesn't benefit from C51 calibration,
  so Q's improved knowledge stays trapped in the critic head.

  Deep audit revealed a self-reinforcing defensive trap:
    Q learned "big positions lose money" → π_target favors small
    actions → π picks a3+a4 (tiny long / Hold) → position lots ≈ 0
    → rewards mostly 0 → V learns "everything is 0" → V_pred ≈ 0
    → advantage = returns - V_pred ≈ 0 → PPO gradient ≈ 0 → π
    frozen at defensive attractor → loop. Trade count dropped 3×
    (rdgzl 25k → 8xwq8/vj5f6 9k closes per 10k steps), win rate
    inversely correlated with l_q (50% early → 22% late) because
    only forced closes happen (stops = losses).

  Fix: new rl_q_pi_distill_grad.cu computes
    π_target = softmax(E_Q[s,*] / τ)
    ∂L/∂logits[a] = λ × (π_new(a) - π_target(a))
  and ADDS this gradient to pi_grad_logits AFTER the PPO surrogate
  backward. Couples Q's preferences directly into π's update without
  going through advantage. λ=0.01 (small, PPO dominant), τ=1.0
  (canonical Boltzmann). 3 new ISV slots (λ + τ + KL_ema diag).

Diag exposes c51_v_max/v_min, q_distill_lambda/temperature, and
q_distill_kl_ema so the adaptation + distillation loop is observable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 13:47:21 +02:00
jgrusewski
2d498bec3a feat(rl): adaptive C51 atom span ratchet to lift Q learning ceiling
rdgzl follow-up — chain hypothesis layer 2:
  reward clamp lift unlocked V regression + PPO advantage (R/done
  -$1.39 → -$0.48), but Q's distributional learning was structurally
  capped at hardcoded V_MAX=1.0 in bellman_target_projection.cu —
  any Bellman target > 1.0 categorically projected to atom 20 (top)
  regardless of clamp. Even with WIN=3.8 clamp, Q never saw a +3.8
  reward signal as distinct from a +1.0 reward signal.

This commit makes V_MIN/V_MAX ISV-driven with monotone-grow ratchet
coupled to the reward clamp. The C51 distribution support adapts
WITHOUT destabilising Q's learned values — atom 20 always represents
at least the widest WIN we've ever admitted (only grows, never shrinks).

Implementation:
  - 2 new ISV slots (484 V_MAX, 485 V_MIN) with [-1, +1] floors
    seeded by rl_isv_write
  - rl_reward_clamp_controller.cu also ratchets these slots:
    V_MAX_new = max(V_MAX_prev, max(1.0, WIN_clamp))
    V_MIN_new = min(V_MIN_prev, min(-1.0, -LOSS_clamp))
  - bellman_target_projection.cu reads V_MIN/V_MAX from ISV, derives
    DELTA_Z inline (was #define)
  - New rl_atom_support_update.cu (21-thread block) refreshes
    atom_supports_d = linspace(V_MIN, V_MAX, 21) per step so
    downstream C51 kernels (argmax_expected_q, rl_action_kernel,
    dqn_distributional_q) see the current span
  - Trainer launches atom-support updater after each reward-clamp
    controller launch (both helper + step_with_lobsim inline paths)
  - Diag exposes c51_v_max + c51_v_min for adaptation visibility

Floors at [-1, +1] preserve original C51 design as hard minimum —
the atom support can only become wider, never narrower than the
baseline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 13:22:31 +02:00
jgrusewski
13084f7746 feat(rl): MARGIN adaptive from clip-rate + remove MAX_WIN cap
rdgzl follow-up — chain hypothesis test:
  clip rate stayed at 25-40% across windows (target ~5%)
  win rate oscillated 27-47% with no clear trend
  positive-tail distribution: p50=1.85 p90=10.1 p99=76.9 max=2230
  MAX_WIN=20 hit ceiling in EVERY window (load-bearing cap)
  static MARGIN=1.5 couldn't chase the tail

Two interventions in one commit:

(1) MARGIN is now adaptive in rl_reward_clamp_controller.cu via a
    Schulman bounded-step on clip-rate EMA vs target:
      clip_indicator = (pos_max > current_WIN && pos_max > 0) ? 1 : 0
      clip_rate_ema  = (1-α) * prev + α * indicator   (α=0.05)
      if clip_rate_ema > target × 1.5 → MARGIN *= 1.2 (up to MAX_MARGIN=5)
      if clip_rate_ema < target / 1.5 → MARGIN /= 1.2 (down to MIN_MARGIN=1)
    Target clip rate seeded at 0.05 — accept 5% tail outliers, capture
    the rest. Two new ISV slots (482 clip-rate EMA, 483 target).

(2) MAX_WIN cap REMOVED — the hardcoded ceiling defeated the purpose
    of adaptation. Safety reasoning: WIN = MARGIN × pos_max_ema with
    MARGIN ∈ [1, 5] and pos_max_ema bounded by reward_scale × raw_PnL
    (both finite). MIN_WIN=1.0 floor retained.

Diag exposes clip_rate_ema + reward_clamp_clip_rate_target so the
adaptation loop is observable in the JSONL.

KNOWN DOWNSTREAM CEILING: bellman_target_projection.cu hardcodes C51
atom span at V_MIN=-1.0, V_MAX=+1.0. Any Bellman target outside this
range is categorically clipped regardless of our reward clamp. So
lifting WIN > 1.0 helps V regression + PPO advantage (which see real
magnitude) but Q's distributional learning is structurally capped at
V_MAX=1.0. A separate intervention to lift C51 V_MAX would be needed
to unlock Q's atom-distribution learning beyond +1.0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 13:10:34 +02:00
jgrusewski
51b9f46364 feat(rl): adaptive reward clamp from positive-tail EMA
alpha-rl-rmgm5 (commit a776fab31) deep-diag finding:
  - static `[-3, +1]` clamp fired on 85% of steps
  - pre-clamp max: p95=15.5  p99=45.2  max=2830 (in WIN-bound units)
  - win distribution avg=+$2.06 max=+$11 squished to +1.0
  - loss distribution avg=-$3.84 routinely exceeded -3.0
  - per-trade EV = 0.357 * 2.06 + 0.643 * (-3.84) = -$1.74

The static clamp was crushing the gradient differential between
profitable and unprofitable trades, leaving Q with no signal to
distinguish good actions from bad. Adaptive bounds let the actual
winning-trade distribution reach the C51 atom support.

Implementation:
  - apply_reward_scale.cu: dual reduction (max|scaled| + max(positive
    scaled, 0)); positive-tail published to new ISV slot 478
  - rl_reward_clamp_controller.cu: maintains EMA of slot 478 in slot
    479 via Wiener-α blend (floor 0.4 per pearl_wiener_alpha_floor);
    writes WIN_eff = clamp(MARGIN * EMA, [1.0, 20.0]) to slot 452
    and LOSS_eff = RATIO * WIN to slot 453
  - 4 new ISV slots (478-481): raw + EMA + margin + ratio
  - Trainer per-step launch added at both apply_reward_scale sites
    (helper method + step_with_lobsim inline path)
  - Shared-mem bytes doubled at both apply_reward_scale launches
  - Static-default seeds added to with_controllers_bootstrapped
    (MARGIN=1.5, RATIO=3.0) — controller's bootstrap-on-sentinel
    path takes over from these once first positive reward observed
  - Diag JSONL exposes pos_scaled_max, pos_scaled_max_ema, and the
    margin/ratio config

Preserves 3:1 loss-aversion asymmetry per
pearl_audit_unboundedness_for_implicit_asymmetry — RATIO is itself
ISV-tunable. WIN floor 1.0 / ceiling 20.0 are hardcoded per the
user-stated "floors and clamp bounds" exemption (2026-05-24).

Adds #![recursion_limit = "256"] to alpha_rl_train.rs — the diag
json! block crossed serde_json's default 128-arg expansion budget.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 12:36:12 +02:00
jgrusewski
a776fab31f fix(rl-cli): build B×K snapshot tensor per step at b_size>1
Crash in alpha-rl-ljn8k (commit 9c6c280bd) at step 0:
  forward_only: expected 512 snapshots (B=16 * K=32); got 32

Root cause: CLI binary called next_sequence_pair() once per step
and passed the resulting K-snapshot window to step_with_lobsim. At
b_size=1 the encoder's B×K=32 contract matched; at b_size=16 it
silently expected B×K=512 and bailed.

Fix: sample n_backtests INDEPENDENT pairs per step and concat into
B×K row-major layout. This is the "proper" per-batch market
diversity that delivers the gradient-variance reduction promised
by `pearl_b_size_1_signal_starvation_blocks_q_learning` —
tiling one window B times would be bit-identical encoder input
across batch slots and contribute zero encoder gradient diversity.

Bumps loader cap from n_steps×2 to n_steps×n_backtests×2 so the
extra pair-sampling doesn't EOF mid-run. Eval loop fixed
identically.

Staging buffers preallocated outside the step loop to avoid
per-step Vec alloc thrash at the hot path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 11:38:37 +02:00