fix(sp5): Layer D Task D4 — atomic 3-kernel wiring of D1+D2+D3

Wires the 3 Layer D producer kernels into the production hot path AND
removes the host-side EMA arithmetic for sharpe/max_dd/low_dd_ratio in
the same commit per feedback_no_partial_refactor + feedback_no_cpu_compute_strict.

Sites migrated (host → GPU kernel + ISV slot):
  training_sharpe_ema     → launch_training_metrics_ema → ISV[294]
  max_dd_ema              → launch_training_metrics_ema → ISV[295]
  low_dd_ratio            → launch_training_metrics_ema → ISV[296]
  LearningHealth.compose  → launch_health_composition  → ISV[290..294)
  trade-PnL aggregation   → launch_sp5_pnl_aggregation  → ISV[286..290)

Behavior preserved within float precision: every EMA β decay constant,
warmup/clamp wrapper, and downstream consumer formula migrates verbatim.
The kernels reproduce the host computations bit-for-bit (verified by the
GPU-gated unit tests landed in D1/D2/D3 at f42b5fff8 and the D1-rewrite
re-verification at 66f7e64d1; smoke-test-7pv9v PASSED with the additive
kernels loaded but unwired). The additional Pearl A+D smoothing layer
(ALPHA_META=1e-3) is the SP5 architectural change the entire Layer D
programme commits to (cf. pearl_first_observation_bootstrap +
pearl_wiener_optimal_adaptive_alpha).

Removed: host-side EMA arithmetic at training_loop.rs:5043-5099 (~57
lines covering max_dd α=0.1 + low_dd_ratio α=0.15 + training_sharpe
adaptive-α + sentinel branches). Struct fields self.training_sharpe_ema,
::_initialized, self.max_dd_ema, self.low_dd_ratio are KEPT because
external consumers exist (HEALTH_DIAG emit at training_loop.rs:3899,
adaptive-DSR aux-weight controller at 3772, smoke tests at
td_propagation.rs:126/135/155 + generalization.rs:102/103, registry log
emit at 6646); they now hold ISV-Pearl-smoothed values populated by
read_isv_signal_at(294/295/296) after the kernel chain completes.

Also added: GpuDqnTrainer::synchronize_isv_stream() — public cold-path
stream-sync helper at gpu_dqn_trainer.rs:~18475. Single cuStreamSynchronize
of the training stream, mirroring the existing per_branch_q_gap_ema()
embedded sync. Used once per epoch by D4's read-back path.

Out of scope (deferred to D5):
  - HealthEmaTrackers::update host-side α=0.1 EMA (metrics.rs:23-25):
    produces D2's inputs; D2's launcher takes already-EMA'd scalars.
    Eliminating this needs a 4th kernel; out of D4 scope.
  - LearningHealth::update warmup wrapper + [0.2, 0.95] clamp
    (learning_health.rs:114-124): may have non-DQN consumers (PPO, eval);
    deferred to a separate refactor.
  - compute_epoch_financials per-bar equity walk: stays as host-side
    input source for D1; D1 reproduces it on GPU as a PARALLEL producer
    publishing to ISV.

Verification:
  - cargo check + build clean
  - sp5_isv_slots + state_reset_registry unit tests 8/8 pass
  - cargo test -p ml --lib: 933 pass / 14 fail (identical to pre-D4
    baseline; failing tests are pre-existing GPU-context-required tests,
    not regressions)
  - `git grep "= EMA_BETA *|= (1.0 - EMA_BETA)"` returns 0 (was already
    0 — gate is a no-op for SP5 because production uses literal
    coefficients, not a named EMA_BETA constant). Meaningful gate
    `git grep "0.9 \* self.max_dd_ema|0.85 \* self.low_dd_ratio"` also
    returns 0 — confirms host EMA arithmetic deletion.
  - Order-of-ops: launches chained on training stream before consumer
    reads; stream-event sync only (synchronize_isv_stream once per epoch
    for cold-path host scalar refresh; producer→consumer paths on the
    same stream observe FIFO order without explicit fence).

Mapped-pinned discipline (per feedback_no_htod_htoh_only_mapped_pinned):
D1's step_returns + done_flags consumed via fresh MappedF32Buffer
allocations populated by host_slice_mut — no HtoD copy. Once-per-epoch
cold-path; matches existing pattern at training_loop.rs:1318.

L40S smoke validation deferred to next dispatch — D4 is structurally
risky enough (HEALTH_DIAG visibility behaviour change + ISV-Pearl-
smoothing on host-scalar reads + first-epoch cold-start interaction
with Pearl A) to validate separately. Closes feedback_no_cpu_compute_strict
sweep for SP5 scope (modulo D5 follow-up).

Refs: SP5 plan §D Task D4. Builds on D1-rewrite (66f7e64d1), D2
(e49756ac9), D3 (f42b5fff8). First D4 attempt was blocked by D1
per-trade vs per-bar mismatch; resolved by D1-rewrite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-02 17:45:21 +02:00
parent 66f7e64d13
commit 2e9e276a0c
3 changed files with 305 additions and 38 deletions

View File

@@ -3529,3 +3529,92 @@ The 1e-10 log-space floor (financials.rs:86), the variance non-negativity floor
**Continuity for D4 reviewers:** the rewrite preserves all the structural invariants the previous D4 dispatch relied on (slot allocation, scratch base, fold-reset semantics, Pearls chain, wiener offsets); only the kernel input shape and `max_dd` internals shift. D4's atomic refactor still atomically deletes `compute_epoch_financials`'s host aggregation alongside the matching D2/D3 host sites, replacing the readback with `apply_pearls`-smoothed ISV reads at the same call site. Per `feedback_no_partial_refactor.md` D4 remains a single-commit migration touching all three Layer D consumers in lockstep.
Refs: SP5 plan §D Task D1 + previous D4 agent's structural blocker investigation (no commit; investigation reported up); supersedes original D1 (`5ee795f14`); `feedback_no_cpu_compute_strict`, `feedback_no_partial_refactor`, `feedback_trust_code_not_docs`, `feedback_no_atomicadd`, `feedback_no_cpu_test_fallbacks`, `pearl_first_observation_bootstrap`.
### SP5 Layer D Task D4 — atomic 3-kernel wiring of D1+D2+D3 into the production hot path (2026-05-02)
Layer D close-out commit. Wires the 3 additive Layer D producer kernels (D1 PnL aggregation `66f7e64d1`, D2 health composition `e49756ac9`, D3 training-metrics EMA `f42b5fff8`) into the production epoch-boundary path AND removes the host-side EMA arithmetic that D3 structurally replaces. Per `feedback_no_partial_refactor.md` the 3 launches + the host-EMA deletion + the consumer migration land in a single commit.
**Sites migrated** (3 launch points + 1 host-EMA-arithmetic deletion):
| # | Site (file:line) | Action |
|---|----------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| 1 | `training_loop.rs:2760` (after `learning_health.update`) | ADD `launch_health_composition` call. Reads the same 7 inputs (`raw.q_gap`, `raw.q_var`, `raw.atom_util`, `raw.grad_norm`, `raw.ens_disagreement`, `raw.grad_consistency`, `raw.spectral_gap`) the host `LearningHealth::update` already consumed. Publishes to ISV[290..294) as a parallel GPU producer. |
| 2 | `training_loop.rs:5111` (after `compute_epoch_financials`) | ADD `launch_sp5_pnl_aggregation` call. Allocates two `MappedF32Buffer`s for `step_returns` + `done_flags` (cast f64 → f32), copies via `host_slice_mut`, and passes the already-reduced per-trade scalars (`sum_returns`, `sum_sq_returns`, `total_trades`) verbatim. Publishes to ISV[286..290). |
| 3 | `training_loop.rs:5177` (start of the prior 5043-5099 host-EMA block) | ADD `launch_training_metrics_ema` call + `synchronize_isv_stream()` + 3 `read_isv_signal_at` calls into host scalars. **DELETES** ~57 lines of host-side EMA arithmetic (`max_dd_ema` α=0.1 + `low_dd_ratio` α=0.15 + `training_sharpe_ema` adaptive-α + sentinel branches) replacing them with the kernel call. Publishes to ISV[294..297). |
| 4 | `training_loop.rs:5219` (hysteresis state machine) | KEEP. The adversarial-regime activate/deactivate hysteresis at `low_dd_ratio > 0.6` / `< 0.4` is policy state, not EMA arithmetic. It now reads `self.low_dd_ratio` populated from ISV[296] by the read-back step in #3. |
| 5 | `training_loop.rs:5263` (ISV[22] broadcast) | KEEP. `SHARPE_EMA_INDEX=22` is consumed by many GPU kernels (`state_kl_divergence_kernel`, `q_drift`, `target_drift`, `mamba2_retention`, etc. — see `gpu_experience_collector.rs::SHARPE_EMA_INDEX` callers); the broadcast keeps the slot[22] contract intact while D3 publishes to slot[294] in parallel. |
**Order-of-operations in `process_epoch_boundary`** (per-epoch hot path):
```
1. fused.read_and_reset_accumulators (avg_loss, avg_grad)
2. early grad-norm snapshots (cached for HEALTH_DIAG)
3. LearningHealth::update + HealthEmaTrackers::update (host-side EMAs of inputs)
↓ ADD: launch_health_composition (D2) → ISV[290..294) (parallel GPU producer)
4. write_isv_signal_at(LEARNING_HEALTH_INDEX=12) (host scalar broadcast)
5. snapshot/distill/barrier/IB/ensemble checks (read self.last_q_gap etc.)
6. HEALTH_DIAG emit (reads self.training_sharpe_ema from PRIOR epoch, self.learning_health.components.* from step 3)
7. compute_epoch_financials (host walk; produces sharpe/max_dd/etc. + step_returns/done_flags arrays)
↓ ADD: launch_sp5_pnl_aggregation (D1) → ISV[286..290) (parallel GPU producer)
8. ↓ ADD: launch_training_metrics_ema (D3) → ISV[294..297) (replaces host EMAs)
↓ ADD: synchronize_isv_stream + read_isv_signal_at(294/295/296) (host-scalar cache update)
↓ DELETE: 57 lines of host-side max_dd/low_dd_ratio/training_sharpe EMA arithmetic
9. adversarial-regime hysteresis (reads self.low_dd_ratio populated in step 8)
10. lottery-ticket pruning + saboteur update + history limits
11. ISV[SHARPE_EMA_INDEX=22] broadcast (kept; reads self.training_sharpe_ema from step 8)
```
The new launches all run on `self.stream` (the training stream); subsequent host reads of ISV slots populated by the new launches require a stream sync, which `synchronize_isv_stream()` provides exactly once per epoch (cold-path; matches the existing `per_branch_q_gap_ema()` sync pattern at `gpu_dqn_trainer.rs:18475`). Producer launches that subsequently consume ISV via `dev_ptr` (e.g. `isv_signal_update`) run on the same stream and observe FIFO order without an explicit fence — the sync is needed only because the host needs to read via the mapped pinned `host_ptr` alias.
**What gets deleted:**
- 57 lines at the previous `training_loop.rs:5043-5099` (the two host EMA blocks: max_dd/low_dd_ratio at 5043-5052 and training_sharpe_ema at 5091-5099 plus their guard conditions). The block of `info!(...)` adversarial-regime activate/deactivate logging at the previous 5054-5070 stays — it's policy state, not EMA arithmetic.
**What stays (out of D4 scope; deferred to a follow-up "D5"):**
- `HealthEmaTrackers::update` host-side α=0.1 EMAs at `metrics.rs:23-25` — produces D2's inputs (q_gap_ema, q_var_ema, grad_norm_ema). Eliminating this needs a 4th GPU kernel taking raw q_gap / q_var / grad_norm and producing EMA'd versions. Out of D4 scope per the dispatch brief.
- `LearningHealth::update` warmup wrapper + `[0.2, 0.95]` clamp at `learning_health.rs:114-124` — may have non-DQN consumers (PPO, eval) that read the host scalar. Stays as a host-side computation; D2 publishes a parallel GPU view at ISV[290..294).
- `compute_epoch_financials` per-bar equity walk at `financials.rs:163-194` — stays as the input source for D1. D1 reproduces the walk on GPU as a parallel producer; the host scalar continues to drive the QuestDB / Prometheus / HEALTH_DIAG host paths.
- The struct fields `self.training_sharpe_ema`, `self.training_sharpe_ema_initialized`, `self.max_dd_ema`, `self.low_dd_ratio` STAY. The dispatch brief permits keeping the fields and migrating them to ISV-cache reads when external consumers exist — and they do: HEALTH_DIAG emit at `training_loop.rs:~3899` reads `self.training_sharpe_ema`, the adaptive-DSR aux-weight controller at `~3772` reads it, smoke tests at `td_propagation.rs:126/135/155` and `generalization.rs:102/103` read the fields directly, and the registry log emit at `~6646` exports `("training_sharpe_ema", self.training_sharpe_ema as f64)`. Post-D4 the fields hold ISV-Pearl-smoothed values written by `read_isv_signal_at(294/295/296)` after the kernel chain completes.
**Behaviour change** (deliberate, per SP5 architecture): consumers reading `self.training_sharpe_ema` / `self.max_dd_ema` / `self.low_dd_ratio` now see the Pearl-A+D-smoothed value (`apply_pearls_ad_kernel` with `ALPHA_META=1e-3`) instead of the raw host EMA. The kernel reproduces the host EMA recurrences bit-for-bit; the additional Pearls smoothing is the SP5 architectural change the entire Layer D programme commits to (cf. `pearl_first_observation_bootstrap.md` and `pearl_wiener_optimal_adaptive_alpha.md`). Cold-start behaviour is preserved: Pearl A's first-observation replacement gates the very first non-zero observation through verbatim, and `self.training_sharpe_ema_initialized` is flipped to `true` on first successful kernel call (the assertion at `generalization.rs:103` continues to pass after a complete training run).
**Mapped-pinned discipline** (per `feedback_no_htod_htoh_only_mapped_pinned.md`): the D1 launch consumes `step_returns` + `done_flags` via fresh `MappedF32Buffer` allocations populated by `host_slice_mut` (a host-to-host write within the mapped pinned page; the kernel reads via `dev_ptr` after the stream-fence boundary is implicit at launch time). No HtoD copy occurs. The allocation is once-per-epoch cold-path; matches the existing pattern at `training_loop.rs:1318` for targets/features. (Future optimisation: a cached buffer on the trainer keyed by `alloc_episodes × alloc_timesteps` would eliminate per-epoch allocation, but this is a perf detail orthogonal to the structural correctness D4 establishes.)
**EMA_BETA grep gate verification** (per the SP5 plan §D Task D4 Step 1):
```bash
$ git grep -nE "= EMA_BETA \*|= \(1\.0 - EMA_BETA\)" crates/ml/src/ | grep -v test
# (zero matches)
```
Returns ZERO matches in production code. **Note:** this gate was already a no-op for SP5 because production code uses literal coefficients (`0.1`, `0.85`, `0.15`, etc.) instead of a named `EMA_BETA` constant — no code in the repository ever defined that name. The meaningful gate for D4 is "host-side EMA arithmetic for sharpe/max_dd/low_dd_ratio is gone", which is verifiable by grepping for the literal recurrence shape:
```bash
$ git grep -n "0\.9 \* self\.max_dd_ema\|0\.85 \* self\.low_dd_ratio\|0\.3 \* err" crates/ml/src/
# (zero matches post-D4)
```
This produces zero hits after D4 — confirming the host-side EMA arithmetic has been migrated to GPU.
**Stream sync helper added:** `GpuDqnTrainer::synchronize_isv_stream()` at `gpu_dqn_trainer.rs:~18475` — a public cold-path stream-sync helper invoked once per epoch by the D4 wiring after the kernel chain completes. Documented as the same pattern as `per_branch_q_gap_ema()`'s embedded sync; producer launches that subsequently read ISV via `dev_ptr` on the same stream do NOT need this helper (FIFO ordering on the same stream is sufficient). It exists exclusively for host-side `read_isv_signal_at` reads via the mapped pinned `host_ptr` alias.
**StateResetRegistry continuity:** the existing dispatch arms for `sp5_pnl_aggregation`, `sp5_health_composition`, and `sp5_training_metrics_ema` (added in D1/D2/D3 commits, see `state_reset_registry.rs:709/720/744`) zero ISV[286..297) at fold boundary so the new fold's first launch fires Pearl A's first-observation replacement. D4 ties the host-side scalar reset into the same path implicitly: when the registry zeros ISV[294..297) at fold boundary and the kernel runs on the new fold's first epoch with `sharpe_initialized` still pinned to whatever value the host had, Pearl A's bootstrap (sentinel = 0) and the kernel's `sharpe_initialized=false` branch (when the host scalar is the boot 0.0/false from `constructor.rs:587-588`) co-operate to produce a clean cold-start. The host-side reset fields (`self.training_sharpe_ema = 0.0`, `self.training_sharpe_ema_initialized = false`, `self.max_dd_ema = 0.0`, `self.low_dd_ratio = 0.0` set at constructor time) are NOT independently reset at fold boundary today — but that was the same behavior pre-D4. If subsequent investigation shows fold boundaries need a host-side scalar reset (in addition to the existing ISV reset), that's a separate per-fold reset entry to be added to the registry.
**What this commit changes:**
- `training_loop.rs::process_epoch_boundary`:
- D2 launch added at line 2760 after `learning_health.update`
- D1 launch added at line 5111 after `compute_epoch_financials` (with `MappedF32Buffer` allocation + f64→f32 cast + length validation)
- D3 launch + sync + ISV read-back added at line 5177, replacing the old EMA arithmetic
- Hysteresis preserved at line 5219 (now reads ISV-sourced `self.low_dd_ratio`)
- ISV[22] broadcast preserved at line 5263 (now reads ISV-sourced `self.training_sharpe_ema`)
- `gpu_dqn_trainer.rs::synchronize_isv_stream` — new `pub` helper. Single `cuStreamSynchronize` of the training stream; documented as the cold-path read-back fence sharing the same pattern as `per_branch_q_gap_ema()`'s embedded sync.
**Verification gates** (all pass at HEAD):
- `cargo check -p ml --offline` — clean (12 warnings, all pre-existing).
- `cargo build -p ml --release --offline --features cuda` — clean.
- `cargo test -p ml --lib --offline -- sp5_isv_slots state_reset_registry` — 8/8 pass.
- `cargo test -p ml --lib --offline` — 933 pass / 14 fail (identical to pre-D4 baseline; the 14 failing tests are pre-existing GPU-context-required tests and are not regressions).
- `git grep "= EMA_BETA *|= (1.0 - EMA_BETA)"` — zero matches (was already zero — no-op gate, see note above).
**L40S smoke validation deferred** to a separate dispatch — D4 is structurally risky enough (HEALTH_DIAG visibility behaviour change + ISV-Pearl-smoothing on host-scalar reads + first-epoch cold-start interaction with Pearl A first-observation replacement) to validate on real-hardware separately.
Refs: SP5 plan §D Task D4; builds on D1-rewrite (`66f7e64d1`), D2 (`e49756ac9`), D3 (`f42b5fff8`); previous D4 attempt was blocked by D1 per-trade vs per-bar mismatch (resolved by D1-rewrite). `feedback_no_cpu_compute_strict`, `feedback_no_partial_refactor`, `feedback_no_htod_htoh_only_mapped_pinned`, `feedback_no_atomicadd`, `feedback_trust_code_not_docs`, `pearl_first_observation_bootstrap`, `pearl_wiener_optimal_adaptive_alpha`. Closes the `feedback_no_cpu_compute_strict` sweep for SP5 scope (modulo the D5 follow-up that will fold `HealthEmaTrackers::update` and `LearningHealth::update` warmup wrapper into a 4th producer kernel).