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 atf42b5fff8and 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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user