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>
The original D1 kernel (commit 5ee795f14) was authored against a brief
that assumed per-trade event arrays exist in production. They don't.
Production has per-bar step_returns + done_flags with episode-boundary
resets, plus already-aggregated summary scalars (sum_returns,
sum_sq_returns) on TradeStats. The previous D4 agent found this
mismatch as a structural blocker and stopped before any code changes.
This rewrite fixes the kernel internals to match compute_epoch_financials
in financials.rs bit-for-bit. The slot allocation (ISV[286..290) for
PNL_TOTAL/MEAN/VAR/MAX_DD), Pearls A+D chain, and reset registry are
unchanged — only the kernel interface and max_dd internals shift.
What changed:
- pnl_aggregation_kernel.cu: full rewrite. New signature consumes
step_returns[N], done_flags[N], num_bars, n_trades, sum_returns,
sum_sq_returns, initial_capital. pnl_total uses log-space
compounded growth (financials.rs:80-97). pnl_mean/pnl_var are
per-trade (financials.rs:122-124). pnl_max_dd implements per-bar
equity walk over last 10K bars with reset at done_flags > 0.5
AFTER processing the bar's return (matches financials.rs:163-194
line-by-line, including 1.0 cap).
- launch_sp5_pnl_aggregation: signature updated to match. 2
MappedF32Buffers + 5 i32/f32 scalars instead of 3 mapped-pinned
buffers + 1 count. Same Pearls A+D chain on the same stream.
- sp5_isv_slots.rs: PNL slot docstring updated to reflect the actual
output semantics (slot constants unchanged).
- SCRATCH_PNL_AGG_BASE docstring + cubin static docstring + field
docstring updated.
- sp5_producer_unit_tests::pnl_aggregation_kernel_correctness:
rewritten with new oracle. Inputs cover an episode boundary at
bar 2 so the reset semantics are observable; expected values
derived analytically from the host formula (no CPU oracle in the
test process, per feedback_no_cpu_test_fallbacks).
- Audit doc: D1-rewrite entry supersedes original D1 entry, with a
line-by-line formula provenance table for D4 reviewers.
Unchanged: ISV slot allocation (sp5_isv_slots.rs constants),
SP5_SLOT_END/SP5_PRODUCER_COUNT/ISV_TOTAL_DIM, wiener offsets,
state_reset_registry arm + dispatch, build.rs cubin registration.
The kernel filename stays the same; only the file contents change.
Formula fidelity provenance (financials.rs as the oracle):
- pnl_total: lines 80-97 (log-space sum, 1e-10 floor, exp − 1)
- pnl_mean: line 122 (per-trade sum_returns / n_trades)
- pnl_var: lines 123-124 (per-trade E[X²] − E[X]², .max(0))
- pnl_max_dd: lines 163-194 (10K window, post-update reset, 1.0 cap)
Tests: cargo check + cargo build --features cuda clean; ISV slot +
state_reset_registry tests pass (8/8 unchanged); rewritten GPU
correctness test fires on next L40S smoke.
This unblocks D4 atomic wiring (D1+D2+D3 into the production hot
path with host-EMA removal). D4 dispatch resumes once this lands.
Refs: SP5 plan §D Task D1 + the structural blocker investigation by
the prior D4 agent (no commit; investigation reported up).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Third of 3 Layer D producer kernels. Replaces host-side training_sharpe_ema,
max_dd_ema, low_dd_ratio updates (host-side EMAs in training_loop.rs) with
a fused GPU kernel chained through apply_pearls_ad_kernel. Per
feedback_no_cpu_compute_strict.
Note: agent investigated training_loop.rs and found the third metric is
low_dd_ratio (not gamma_blend as the original plan brief named). The
authored kernel reproduces the actual host-side EMA triplet present in
the codebase.
Additive only — no consumer wiring, no behavior change. The kernel +
launcher are loaded into GpuDqnTrainer and the slots are reserved on the
ISV bus, but the host-side updates continue to run unchanged. D4 (atomic
Layer D commit) wires this and D1+D2 to call sites in the same atomic
refactor per feedback_no_partial_refactor. Also note: training_loop.rs
gains two state-reset-registry dispatch arms (sp5_health_composition for
D2 + sp5_training_metrics_ema for D3) — registry plumbing required by
the new entries, NOT consumer wiring of the kernels themselves; same
pattern as SP5 Layer A bug-fix #281.
What this lands:
- training_metrics_ema_kernel.cu (single-block 3-thread fused EMA)
- Rust launcher launch_training_metrics_ema()
- 3 new ISV slots (TRAINING_SHARPE_EMA_INDEX..LOW_DD_RATIO_INDEX, 294..297)
- ISV_TOTAL_DIM 294 → 297, SP5_PRODUCER_COUNT 120 → 123 (linear span)
- LAYOUT_FINGERPRINT_SEED bump (auto via slot string)
- StateResetRegistry entries for D2 (sp5_health_composition) + D3
(sp5_training_metrics_ema) with reset_named_state dispatch arms
- build.rs cubin registration
- GPU-gated unit test in sp5_producer_unit_tests.rs (analytical EMA)
- SP5 contiguity slot test
training_metrics_ema_slots_contiguous_and_above_health_block
- Audit doc append
Formula fidelity: kernel reproduces the host-side EMA update for
sharpe/max_dd/low_dd_ratio bit-for-bit within float precision. β decay
constants and any warmup/clamp logic migrate as Invariant 1 anchors with
no algorithmic change. Verified by unit test asserting kernel output
matches an analytical EMA sequence within 1e-6 rel-err.
Tests: cargo check + cargo build clean; ISV slot + state_reset_registry
unit tests pass (8/8 incl. new contiguity check); GPU correctness test
fires on next L40S smoke.
Refs: SP5 plan §D Task D3, builds on D1 (5ee795f14) + D2 (e49756ac9).
Layer D additive infrastructure complete after this commit; D4 (atomic
5-site host-EMA → GPU migration) is next.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second of 3 Layer D producer kernels. Replaces multi-step host arithmetic
in LearningHealth composition (q_gap_ema + q_var_ema + grad_norm_ema →
composed health score) with a fused GPU kernel chained through
apply_pearls_ad_kernel. Per feedback_no_cpu_compute_strict.
Additive only — no consumer wiring, no behavior change. The kernel +
launcher are loaded into GpuDqnTrainer and the slots are reserved on the
ISV bus, but the host-side composition continues to run unchanged. D4
(atomic Layer D commit) wires this and D1+D3 to call sites in the same
atomic refactor per feedback_no_partial_refactor.
What this lands:
- health_composition_kernel.cu (single-block fused composition)
- Rust launcher launch_health_composition()
- 4 new ISV slots (HEALTH_SCORE_INDEX..GRAD_NORM_NORM_INDEX, slots 290..294)
- ISV_TOTAL_DIM 290 → 294, SP5_PRODUCER_COUNT 116 → 120 (linear span)
- LAYOUT_FINGERPRINT_SEED bump (auto via slot string)
- StateResetRegistry entries (sentinel 0.0; per-fold reset enabled)
- build.rs cubin registration
- GPU-gated unit test in sp5_producer_unit_tests.rs (formula fidelity)
- Audit doc append
Formula fidelity: kernel reproduces the host-side LearningHealth::compose
(or equivalent) computation bit-for-bit within float precision. Migration
is structural only — no algorithmic change. Verified by unit test
asserting kernel output matches a Rust copy of the host formula within
1e-6 rel-err.
Tests: cargo check + cargo build clean; ISV slot + state_reset_registry
unit tests pass; GPU correctness test fires on next L40S smoke.
Refs: SP5 plan §D Task D2, builds on D1 (5ee795f14).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First of 3 Layer D producer kernels. Replaces host-side trade-PnL
aggregation loop in training_loop.rs with a GPU kernel chained through
apply_pearls_ad_kernel for smoothing. Per feedback_no_cpu_compute_strict.
Additive only — no consumer wiring, no behavior change. The kernel +
launcher are loaded into GpuDqnTrainer and the slots are reserved on the
ISV bus, but the host-side aggregation continues to run unchanged. D4
(atomic Layer D commit) wires this and the other two D2/D3 kernels to
call sites in the same atomic refactor per feedback_no_partial_refactor.
What this lands:
- pnl_aggregation_kernel.cu (single-block tree-reduce, no atomicAdd)
- Rust launcher launch_sp5_pnl_aggregation()
- 4 new ISV slots (PNL_TOTAL_INDEX..PNL_MAX_DD_INDEX, slots 286..290)
- ISV_TOTAL_DIM 286 → 290; SP5_PRODUCER_COUNT semantics formalised as
wiener-buffer linear-span (110 → 116; the unique-slot count diverged
from the linear span at D1 — pre-D1 they coincided by accident at the
Pearl 6 carve-out's introduction)
- LAYOUT_FINGERPRINT_FRAGMENT extended with PNL_* entries
- StateResetRegistry sp5_pnl_aggregation entry + dispatch arm
(sentinel 0.0; fold-reset; PnL is NOT cross-fold persistent unlike
Pearl 6 Kelly stats, so it takes the standard sentinel-bootstrap
path per pearl_first_observation_bootstrap)
- build.rs cubin registration
- GPU-gated unit test pnl_aggregation_kernel_correctness with
analytical ground truth (no CPU reference per
feedback_no_cpu_test_fallbacks)
- Audit doc append documenting D1 + the SP5_PRODUCER_COUNT semantic
clarification
Tests: cargo check + cargo build --release --features cuda clean;
ISV slot + state_reset_registry unit tests 6/6 pass (incl new
pnl_aggregation_slots_contiguous_and_above_kelly_block); GPU
correctness test fires on next L40S smoke alongside existing 17 SP5
producer unit tests.
Refs: SP5 plan §D Task D1, validated SP5 Layer A/B at 5845e4403.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Documents the SP5 implementation (110 ISV slots, 8 layer-A producer kernels,
1 atomic Layer B consumer migration). Closes plan task #295 partial — the
8th pearl (pearl_sp5_close_out) and full close-out commit are DEFERRED
until the 50-epoch 3-seed × 3-fold L40S validation (#289) completes.
7 new memory pearls landed:
pearl_per_branch_c51_atom_span — Pearl 1
pearl_per_branch_loss_budget — Pearl 2
pearl_per_branch_noisy_sigma — Pearl 3
pearl_per_group_adam_hyperparams — Pearl 4
pearl_per_branch_iqn_tau_schedule — Pearl 5
pearl_kelly_cap_signal_driven_floors — Pearl 6 (cross-fold-persistent)
pearl_trail_stop_signal_driven — Pearl 8
Audit doc append covers Pearl 6's cross-fold carve-out, Pearl 4's ε-only
fall-back path, and the smoke-test-ks2wf @ 5845e4403 validation metrics
(sharpe 4.5→9.77, label_scale 19-28, trade_count 6468-9432).
Refs: SP5 spec at 6e6e0fa11
Direct inspection of ES.FUT_2024-Q1.dbn.zst (databento python client, offline
kubectl-cp from PVC) pinpoints the source of the 8,799 corrupt bars that
sanitize_bars (Fix 25) caught at the bar-level gate.
Q1 file content:
Outrights (correct): ESH4/ESM4/ESU4/ESZ4/ESH5 — 43,353 records (76.87%)
price range $5,063–$5,478
Spreads (poison): ESH4-ESM4/ESM4-ESU4/... — 13,048 records (23.13%)
price range $47.30–$219.70
Calendar spreads trade at the price DIFFERENCE between adjacent contracts
(~$60-150 cost-of-carry roll basis). Databento's stype_in=parent resolution
for ES.FUT returns BOTH outrights AND every spread combination in the same
DBN stream. The legacy decoder keyed only by ts_event + dedup-by-volume;
during low-volume overnight windows + active rollover periods, spread bars
beat the outright on volume and survived the dedup. 780 spread bars
survived in Q1 alone; ~8,800 across 2024-2026.
Fix: build dbn::TsSymbolMap from metadata once, resolve each record's
instrument_id → symbol, skip any symbol containing `-` (spread separator).
Same-ts dedup-by-volume continues to handle legitimate front/back-month
overlap among outrights.
Adds `time = "0.3"` to ml/Cargo.toml (dbn::TsSymbolMap uses time::Date).
Why complementary to the Fix 25 sanitize_bars gate:
- Spread filter (this commit) catches the cause precisely by symbol pattern,
but only for instruments where parent-symbol resolution is the source.
- sanitize_bars catches the symptom universally by close-ratio bound, but
can't distinguish a low-basis spread from a fast-moving outright in
extreme cases.
Both layers active: spread filter dispatches at parser level, sanitize as
defense-in-depth backstop for unknown future contamination shapes.
Validation: `cargo check -p ml --example train_baseline_rl` clean.
Refs: Bug 2 chain (#191, #194, label_scale=5443 leaks), today's
sanitize_bars find (Fix 25). Closes the contamination-source investigation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stops chasing one-off corruption bugs. Three+ historical fixes patched
specific writers (#191 fxcache column-0, Bug 1 target schema,
label_scale=5443 leaks, today's state[0] heavy-tail). Each new
corruption shape found the next hole. This installs a structural
defense so corruption is REJECTED at the data-loading boundary
regardless of source.
Five independent layers, each mandatory:
1. Bar-level sanity (baseline_common::sanitize_bars):
drop bars with non-positive OHLC, high<low, non-finite values, or
close/prev_close outside [0.5, 2.0]. Catches DBN parse glitches,
broker tick errors, near-zero-open bars at source.
2. safe_log_return result clamp (extraction.rs:1246):
ratio.ln().clamp(±0.1). Real-market 1-bar log returns rarely
exceed ±0.05; ±0.1 traps every legitimate move while rejecting
the corruption shape (corrupt bar with bar.open ≈ 0 → ln = -30
→ previously normalized to -30000-magnitude state[0] outliers).
3. validate_features pre-norm bound (extraction.rs:538):
|val| ≤ 5.0 post-extraction. Pre-norm features come from
safe_normalize ([0,1]/[-1,1]), safe_clip (max ±3), or clamped
log-returns (±0.1); ±5 catches extractor invariant breaks.
4. NormStats::normalize post-norm clamp (walk_forward.rs:688):
((val - mean) / std).clamp(±20.0). Even if upstream produces
outliers, every value uploaded to GPU is bounded.
5. Shared validate_normalized_features gate (walk_forward.rs):
single source-of-truth invariant enforced at THREE sites:
- fxcache fast path (after discover_and_load)
- DBN fallback (after normalize_batch)
- precompute writer (before fxcache write — never persist
a poisoned cache)
Removed: DIAG_BUG2 + DIAG_BUG2_v2 one-shot diagnostics
(~125 lines of host-side download + outlier scan in
training_loop.rs). Replaced by structural defense — instrumentation
isn't needed when corruption can't reach state[0].
FEATURE_SCHEMA_HASH auto-bumps via build.rs FNV-1a hash over
SCHEMA_FILES (extraction.rs included). All pre-fix .fxcache files
on PVC are invalidated at load time; ensure-fxcache regen produces
clean cache with new clamps applied.
Why this finally closes the chapter: per-writer fixes are reactive
(land after corruption hits prod). Boundary validation is
proactive — every future regression to extraction or normalization
trips the gate at load, not at epoch 5 of a 50-epoch run. The 5
layers are independent: a bug in any one leaves the others as
backstop.
Validation: cargo check -p ml --all-targets --offline clean.
NormStats unit tests (walk_forward.rs:706+) still pass — clamp +
validate are additive; existing test inputs are well within bounds.
Refs: SP5 Bug 2 (state[0] std=570 outliers on smoke-test-xb78r),
historical #191#210#214#193#195 chains.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Augments the existing DIAG_BUG2 one-shot block with two narrower scans
that disambiguate where state[0] heavy-tail (mean=6e-3, std=570,
mean_abs=20 over 3200 samples on smoke-test-xb78r) actually originates.
H1 (data-source corruption): scan features_buf.host_ptr for bars where
|feat[bar*42 + 0]| > 100. host_ptr aliases the same physical memory
the kernel reads (mapped-pinned) — direct read of the data
state_gather sees. Reports up to 10 outlier bar indices + mean/std/
max_abs over up to 2M bars.
H2 (kernel/gather bug): scan gpu_batch.states/next_states (already
DtoH-downloaded) for sample indices where |state[i*sd + 0]| > 100.
Reports up to 10 outlier sample indices.
Verdict line logged: feat-outliers nonempty → H1; state-outliers
nonempty AND feat-outliers empty → H2.
Context: smoke-test-xb78r at HEAD cba9f25ed went through DBN fallback
(train_baseline_rl.rs:611, "Loaded N bars (ts to ts)" message) — no
fxcache file exists on training-data PVC. DBN-fallback applies z-score
normalization (line 633) which has small stddev for log-return columns;
a single bar with bar.open ≈ 0 produces ln(ratio) = -30 → normalized
= -30000. Few bars in 700K could carry this signature.
Triggers once via static AtomicBool. Pre-graph-capture, no perf impact.
Cleanup gate: remove DIAG_BUG2 block once Bug 2 root cause is fixed
(likely a tighter clamp inside safe_log_return for log-return columns).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related blocks landing together (Bug 2 diagnostic forces the structural
conversion of touched files; pre-commit guard at 5275932f4 enforces zero
*_via_pinned helpers per `feedback_no_hiding`).
== Bug 2 instrumentation (training_loop.rs after collect_experiences_gpu) ==
One-shot AtomicBool-gated DIAG_BUG2 dump of first 5 samples × 5 cols of
both gpu_batch.states and gpu_batch.next_states + col-0 mean/std/mean_abs
across the full batch. Prints once at first rollout — pre-graph-capture so
no hot-path impact. Interpretation key embedded:
mean_abs ~0.001 = normalized log_return ✓
mean_abs ~5000 = raw price ✗ (Bug 2 confirmed)
Resolves the smoke-vs-production state[0] divergence question that pure
static code reading couldn't pin: production aux_label_scale=5300 traces
back through gather kernel to "feat[0] of next_states_buf" but the fxcache
shows feat[0] stddev=1.0 (z-normalized log_return). Either next_states_buf
is populated from a different source than fxcache feat[0], or some kernel
mutates state[0] post-gather. The diagnostic prints both states (post-gather)
and next_states (post-shift) to disambiguate.
== Structural conversion: targets_raw_cuda + features_raw_cuda + 10 sites ==
Field types (DQNTrainer in trainer/mod.rs:621/624):
Option<CudaSlice<f32>> → Option<MappedF32Buffer>
init_gpu_raw_buffers_from_slices (training_loop.rs): clone_to_device_f32_via_pinned
calls (lines 1308/1312) replaced with `MappedF32Buffer::new + write_from_slice`.
Consumer signatures (3 functions across 2 files):
- gpu_experience_collector::collect_experiences_gpu(market_features_buf,
targets_buf): &CudaSlice<f32> → &MappedF32Buffer (each)
- gpu_experience_collector::launch_timestep_loop: same
- gpu_experience_collector::compute_difficulty_scores(targets): same
- decision_transformer::build_dt_trajectories(features_gpu, targets_gpu): same
Launch sites: `.arg(buf)` → `.arg(&buf.dev_ptr)` at all 5 launch_builder
invocations in gpu_experience_collector.rs and the 2 in build_dt_trajectories.
== Cold-path init conversions (forced by guard touching shared files) ==
gpu_dqn_trainer.rs (9 sites → 4 buffer-migration groups):
- spec_u_s1/v_s1/u_s2/v_s2 (4 buffers, explicit)
- spec_u/v macro pairs (alloc_spec_pair! body, expands to ~22 buffers)
- graph_params (cross-branch graph message-passing, 60 floats)
- denoise_params (diffusion Q-refinement MLP, 1800 floats)
- qlstm_weights (xLSTM mLSTM-cell, 528 floats)
gpu_experience_collector.rs (1 site):
- upload_ofi_features → ofi_gpu field type Option<CudaSlice<f32>> → MappedF32Buffer
Inherited from prior worktree-agent attempts (compile clean, included here):
- sel_clip_buf in gpu_dqn_trainer.rs
- RmsNormWeightSet γ buffers in gpu_weights.rs
PPO trainer (trainers/ppo.rs) deferred — not on eval-collapse hot path.
== Validation ==
cargo check -p ml --offline: clean
pre-commit hook (check_no_dtod_via_pinned + Invariant 7 + GPU hot-path guard): pass
Bug 2 diagnostic will fire on next L40S run and print state[0] stats for
first batch. If mean_abs ~0.001 → state[0] is correctly normalized and Bug 2
is elsewhere (maybe label_scale_ema initialization). If mean_abs ~5000 →
state[0] really is raw price; the rollout state-builder is the bug.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug 1 of the eval-Hold-collapse diagnosis. The fxcache target schema
documented in experience_kernels.cu:1556 + cuda_pipeline/mod.rs:508 specifies:
target[0] = preproc_close — log-return-normalized close (network input)
target[1] = preproc_next — log-return-normalized next close
target[2] = raw_close — raw price for portfolio simulation
target[3] = raw_next — raw price
target[4] = raw_open — raw price
target[5] = mid_price_open — MBP-10 midpoint (fallback raw_open)
Both writers — `precompute_features.rs:360` and `data_loading.rs:510` —
violated the contract by storing raw OHLCV close prices in slots [0:1].
Empirical fxcache inspection: target[0..4] mean=$5967, stddev=$582 (raw
prices throughout). The raw-price values at target[0:1] were never directly
consumed by training (production aux head reads next_states[i][0] = MARKET
feat[0] = log_return), but they corrupted any code reading targets per the
documented contract.
Both writers now compute (raw_curr / prev_close).ln() and (raw_next /
raw_curr).ln() for the preproc columns. FXCACHE_VERSION bumped 7→8 to
invalidate existing caches and trigger ensure-fxcache regen.
A second bug — eval label_scale=5300 (raw price magnitude) at production
binary despite source state[0] tracing back to z-normalized log_return —
remains unresolved. Bug 2 instrumentation lands in the next commit; that
runtime trace will pin which production-binary code path injects raw_close
into state[0] post-gather.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related changes installing the structural guard against the SP6 Pearl 5
IQN τ failure mode (root cause fixed at facbf76eb for that one site) and
removing the only remaining orphan callers of the broken pattern.
The bug class. The mapped_pinned::{upload,clone_to_device}_{f32,i32}_via_pinned
helpers are named to suggest "no HtoD per feedback_no_htod_htoh_only_mapped_pinned"
but their bodies do MappedXBuffer::new() + memcpy_dtod_async() +
stream.synchronize(). The DtoD copy and synchronize are both forbidden
inside CUDA Graph capture (CUDA_ERROR_STREAM_CAPTURE_INVALIDATED) and add a
host stall otherwise. The canonical pattern is MappedXBuffer stored directly
+ write_from_slice + kernel reads via .dev_ptr, used by SP4 portfolio_state
and SP6 IQN τ at facbf76eb.
Guard. New check_no_dtod_via_pinned in pre-commit-hook.sh rejects any
staged .rs file calling upload_(f32|i32)_via_pinned or
clone_to_device_(f32|i32)_via_pinned, except mapped_pinned.rs itself. Per
feedback_no_hiding: no suppression marker. Also fixes a pre-existing
silent-skip bug: the gpu-hotpath-guard.sh invocation used
$(cd "$(dirname "$0")" && pwd) which resolved to .git/hooks/ (the symlink's
directory) instead of scripts/, so the guard never ran. Replaced with
readlink -f "$0" + an explicit "guard missing" error branch — silent skip
is worse than no guard.
Orphan deletion. gpu_her.rs carried legacy relabel_batch, generate_random_donors
(CPU), HerBatch, slice_clone_f32, slice_clone_i32 — zero production callers
(verified via grep). Production uses relabel_batch_with_strategy +
generate_random_donors_gpu. The orphan held the only upload_i32_via_pinned
callers in the codebase; per feedback_no_hiding the right fix is delete.
Scope. Eliminates 2 of 47 production *_via_pinned call sites. Remaining 45
across 14 files are cold-path init — graph-capture-fragile and host-stalling
but not breaking operationally. Guard enforces no new calls; existing 45
migrate in subsequent atomic per-buffer commits. After all 45 are converted,
the four helpers themselves get deleted from mapped_pinned.rs.
Validation. Smoke smoke-test-82fjk at facbf76eb succeeded — magnitude
differentiation restored (q_full=0.462 > q_half=0.409 > q_quarter=0.350 vs
baseline frozen Pascal-triangle 0.225/0.280/0.495), eval distribution
unfrozen (eq=0.596, eh=0.404, ef=0.000 vs baseline single-action collapse).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pearl 5's online_taus/target_taus/cos_features were declared as
CudaSlice<f32> (device-only), populated via upload_f32_via_pinned
which does a DtoD copy from a separate mapped-pinned staging buffer.
The DtoD inside CUDA Graph capture triggers
CUDA_ERROR_STREAM_CAPTURE_INVALIDATED and the 'continuing ungraphed'
fallback observed in smoke-test-hhr5q.
This violates feedback_no_htod_htoh_only_mapped_pinned: the rule is
mapped-pinned (cuMemHostAlloc DEVICEMAP) for ALL CPU↔GPU paths. No
DtoD copies, no HtoD copies, no exceptions.
Fix: convert all 3 buffers (online_taus, target_taus, cos_features)
to MappedF32Buffer per-branch [MappedF32Buffer; 4] arrays. Host writes
go directly to host_ptr; IQN kernel reads dev_ptr of the same memory
— no copy step at all. The mem::swap pattern is replaced with pure
selection: activate_branch_taus sets active_branch_idx; kernel launch
sites index online_taus_per_branch[active_branch_idx].dev_ptr.
Eliminates upload_f32_via_pinned calls for these buffers entirely.
Refresh becomes a host write to mapped-pinned host_ptr at fold
boundary; subsequent kernel launches see the write through the
mapped-pinned coherence guarantee after stream sync.
cargo check + cargo build --release + cargo test --lib (sp4 sp5
state_reset_registry: 13/13) all clean. Sanity grep for
upload_f32_via_pinned in gpu_iqn_head.rs returns zero.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
W1 SEMANTIC: Pearl 2 iqn_branch[b] is now consumed at Pearl 5 per-pass
IQN budget. Previous code used iqn_trunk/4 in both parallel + sequential
IQN paths, silently averaging Pearl 2's per-branch IQN differentiation.
Now: iqn_budget_per_branch = iqn_branch[branch_idx] / 4.0 inside the
per-branch loop — sum across 4 branches = iqn_trunk magnitude preserved,
per-branch differentiation preserved. iqn_trunk renamed _iqn_trunk in
the destructure (still in-scope comments only).
W2 DELETE: ATOM_NUM_ATOMS_BASE + NOISY_SIGMA_BASE imports removed from
fused_training.rs:44 — consumers are atoms_update_kernel.cu (Layer B)
and experience_kernels.cu; imports were speculative additions.
W3 DELETE: v_blocks at gpu_iql_trainer.rs:756 — both kernel launches
used v_blocks2; v_blocks was stale dead code.
W4 DELETE: OrderRouter import from ml-dqn/src/dqn.rs — import only,
never used in the file body.
W5 DELETE: get_snapshots_for_timestamp import from data_loading.rs —
imported but never called; only OFICalculator + get_trades_for_bar used.
W6 DELETE: update_target_networks method (42 lines) from ml-dqn/dqn.rs —
orphan with zero call sites; real path uses fused CUDA EMA kernel.
Cascaded: convergence_half_life import deleted (now unused).
W7-W13 FILE-LEVEL #![allow(unsafe_code)]: cublas_algo_deterministic.rs
and sp4_wiener_ema.rs — workspace unsafe_code = "warn" fires for every
unsafe impl/fn; both files require unsafe for CUDA/cuBLAS interop;
pattern matches mapped_pinned.rs, gpu_iqn_head.rs, gpu_weights.rs, etc.
W14-W15 VISIBILITY SCOPE: ControllerPrevValues + ControllerFireCounts
in trainer/mod.rs changed pub → pub(crate); consumed only within ml.
Per feedback_no_hiding: zero #[allow(...)] item-level suppressions added;
2 file-level #![allow(unsafe_code)] follow established crate convention.
cargo check (ml + ml-dqn) + cargo build --release + cargo test --lib
all CLEAN (0 warnings; 13/13 lib tests pass).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Brings in worktree-agent-acd65ada (commit 89fadec24): per-branch IQN
τ schedules via 4 forward passes per step, each with one branch's τ
slab swapped in via mem::swap. ÷4 budget normalization at every
per-branch IQN call preserves total gradient magnitude.
CRITICAL FIX: this merge replaces the SP5 Layer B Pearl 5 implementation
that violated feedback_no_cpu_compute_strict. The old refresh_taus_from_isv
called upload_f32_via_pinned every step inside the CUDA Graph capture
region, triggering CUDA_ERROR_STREAM_CAPTURE_UNSUPPORTED and the
'parent graph capture failed (continuing ungraphed)' fallback observed
in smoke-test-qtn7c at SP5 HEAD.
SP6 Pearl 5 uses pre-uploaded τ slabs + mem::swap pointer arithmetic.
Conflict resolution in fused_training.rs:
- Pearl 5 worktree branched from pre-Pearl-2 HEAD; renamed
iqn_budget → iqn_trunk in 2 sites (parallel + sequential paths) to
match Pearl 2's compute_adaptive_budgets() new return signature.
- Removed redundant single-call apply_iqn_trunk_gradient(iqn_trunk)
from post-join block — Pearl 5's per-branch loop above already
applies iqn_budget_per_branch = iqn_trunk/4 to the IQN trunk gradient
4 times, achieving the same total magnitude with per-branch τ.
cargo check + cargo test --lib (sp4 sp5 state_reset_registry: 13/13)
both clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings in worktree-agent-a4d8a879 (commit ed3fa066b): per-branch σ via
[4]-element mapped-pinned device buffer. add_advantage_noise kernel
indexes σ by branch derived from action_idx % total_actions; Q-value
layout is branch-major contiguous so per-branch σ derivation requires
no forward-pass restructuring.
3 ExperienceCollectorConfig constructors updated.
Resolves Pearl 3 averaging from SP5 Layer B which collapsed 4 per-branch
σ values into a single scalar via training_loop.rs:1747.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Brings in worktree-agent-a284432c (commit f62860ea8): per-branch SAXPY
sub-launches via correction-factor pattern. Trunk gets mean budget,
branch HEAD weights get branch[b]/trunk_mean correction multiplier.
Resolves Pearl 2 averaging from SP5 Layer B (compute_adaptive_budgets)
which collapsed 4 per-branch budgets into a single scalar.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
L40S smoke at SP5 Layer B HEAD (3ad5e011b) failed in 9.4s at first
fold-reset boundary with:
ERROR: fold-reset 'sp5_atom_v_center': StateResetRegistry reset
dispatch: unknown name 'sp5_atom_v_center'.
Every SP5 Layer A task added RegistryEntry blocks but none added the
matching dispatch arms in reset_named_state. Runtime validator catches
this on first fold boundary, crashing all 3 folds before training runs
(0/3 checkpoints saved). Local unit tests verified slot layouts but
never exercised the fold-reset dispatch path — gap masked through
Layer A close-out.
Adds 21 dispatch arms covering all SP5 Layer A FoldReset entries:
Pearl 1: sp5_atom_v_center / v_half / headroom / clip_rate
shared: sp5_branch_entropy / q_var_per_branch
wiener: sp5_wiener_state (no-op — sp4 arm covers full buffer)
Pearl 3: sp5_noisy_sigma / sigma_fraction
Pearl 2: sp5_budget_c51 / iqn / cql / ens / flatness
Pearl 4: sp5_adam_beta1 / beta2 / eps + sp5_grad_prev_buf
Pearl 5: sp5_iqn_tau
Pearl 8: sp5_trail_dist_per_dir
Pearl 1-ext: sp5_atom_num_atoms
Pearl 6 (slots 280..286) intentionally absent — those slots are
cross-fold-persistent (the whole point of A6 per
project_magnitude_eval_collapse_kelly_capped).
Each ISV-range arm zeros its slot range to fire Pearl A's first-
observation sentinel on the new fold's first producer launch.
sp5_grad_prev_buf calls a new reset_sp5_grad_prev_buf bulk-zero
method on GpuDqnTrainer (mirrors reset_sp4_clamp_engage_counters).
sp5_wiener_state is no-op since reset_sp4_wiener_state already
bulk-zeros the full wiener_state_buf [0..543) which includes the SP5
producer block at offsets [213..543).
cargo check + cargo test --lib (sp4+sp5+state_reset_registry: 13/13
pass) both clean. Smoke re-deployment pending.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SP6 sub-project 1 (Pearl 2): converts the 4 SAXPY launchers from a single
scalar budget (mean of 4 ISV branch slots) to per-branch differentiated
scaling via the correction-factor pattern.
Problem: SP5 Layer B read ISV[190..210) per-branch budget slots but
collapsed them to a scalar via sum/4.0, then passed the single scalar to
apply_c51_budget_scale / apply_cql_saxpy / apply_iqn_trunk_gradient.
Branch HEAD parameters received the same budget as trunk, defeating
per-branch differentiation.
Fix: compute_adaptive_budgets() now returns ([f32;4], [f32;4], [f32;4],
[f32;4], f32, f32, f32, f32) — four per-branch arrays + four trunk-mean
scalars. The trunk mean (D3 decision) is used for the full-buffer trunk/value
SAXPY call (preserving SP5 Layer B behavior for shared params). Branch HEAD
parameter slices receive a correction sub-launch:
correction = branch_budget[b] / trunk_mean (skip if |correction-1| <= 1e-6)
After both launches, branch HEAD slice is effectively scaled by
branch_budget[b], trunk/value is scaled by trunk_mean. No double-scaling.
New helpers added to GpuDqnTrainer:
apply_c51_budget_scale_branch(branch_idx, correction): scale_f32_ungraphed
on branch-slice [f32 elements], offset via padded_byte_offset.
apply_cql_saxpy_branch(branch_idx, correction): saxpy_f32_aux on
branch-slice of both grad_buf and cql_grad_scratch.
IQN trunk gradient: uses iqn_trunk (mean of 4 branch IQN budgets) — IQN
backward flows through trunk only; per-branch IQN routing is beyond SP6 scope.
HEALTH_DIAG: three new per-epoch info! lines emit per-branch c51/iqn/cql
budget arrays (dir/mag/ord/urg) after the intent_dist line.
State: per-branch budget arrays cached on GpuDqnTrainer
(last_*_budget_per_branch: [f32;4]) and on DqnTrainer
(last_*_budget_per_branch: Option<[f32;4]>) for diagnostics.
docs/isv-slots.md: updated Pearl 2 slot rows to reflect SP6 consumer wiring.
Verification: cargo check + release build clean (13 warnings, pre-existing).
13 sp5+sp4+state_reset_registry lib tests pass. sp5_producer_unit_tests
--no-run clean. Sanity grep for old sum/4.0 averaging pattern: empty.
Files changed (6): gpu_dqn_trainer.rs, fused_training.rs, constructor.rs,
mod.rs, training_loop.rs, docs/isv-slots.md. No Pearl 3 or Pearl 5 files touched.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GpuIqnHead gains 12 new CudaSlice<f32> buffers (online_taus_branch[4],
target_taus_branch[4], cos_features_branch[4]) allocated at construction time
via alloc_f32. Each slab is [B,N] for taus and [D,N] for cos_features — same
sizes as the existing main buffers.
refresh_taus_for_branch(branch_idx, tau5): uploads one branch's 5-quantile
τ schedule from ISV[IQN_TAU_BASE + b*5 .. +5] to per-branch slabs with cold-start
floor (FIXED_TAUS[q] when ISV slot is zero). No cross-branch averaging.
activate_branch_taus(b) / deactivate_branch_taus(b): symmetric mem::swap helpers
install/restore one branch's slab into self.online_taus/target_taus/cos_features
for a per-branch IQN forward pass. activate→deactivate(b) is its own inverse.
fused_training.rs:
- Tau refresh block calls refresh_taus_for_branch(b, tau5) for all 4 branches,
then refresh_taus_from_isv for the averaged main buffer (CVaR backward compat).
- grad_decomp_snapshot_iqn() moved BEFORE the parallel/sequential fork so the
snapshot is taken before any of the 4 per-branch apply_iqn_trunk_gradient calls.
- Parallel path: 4 sequential IQN passes on iqn_stream; after each pass, event
sync to main stream, apply_iqn_trunk_gradient(iqn_budget/4), re-fork so next
pass starts after main has consumed d_h_s2_buf. iqn_done_event recorded after
all 4 passes.
- Sequential path: 4 sequential IQN passes on main stream; apply_iqn_trunk_gradient
(iqn_budget/4) inline after each pass while d_h_s2_buf holds that branch's result.
- Post-join: single apply_iqn_trunk_gradient removed (now inline); target_ema_update
and PER loss cast remain.
÷4 normalization: both apply_iqn_trunk_gradient call sites use iqn_budget_per_branch
= iqn_budget / 4.0_f32 so 4 × (budget/4) = budget total — matching SP5 Layer B
gradient magnitude contract exactly.
docs/isv-slots.md: add SP6 Pearl 5 consumer wiring section under the SP5 table.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace ExperienceCollectorConfig.noise_sigma: f32 with
noise_sigma_per_branch: [f32; 4] (branch order: dir/mag/ord/urg).
add_advantage_noise kernel (experience_kernels.cu) now takes
const float* noise_sigma[4] + b0/b1/b2/b3 branch-size params.
Each thread derives its branch_idx from action offset using cumulative
branch size offsets; applies that branch's sigma. Sigma=0 fast-exits
with no PRNG work.
GpuExperienceCollector gains noise_sigma_dev: MappedF32Buffer[4]
(mapped-pinned, zero HtoD copy per feedback_no_htod). CPU writes the
4 sigma values via write_from_slice before each kernel launch; kernel
reads via dev_ptr.
training_loop.rs reads ISV[NOISY_SIGMA_BASE..+4] = ISV[210..214)
directly — one slot per branch — instead of averaging all 4 into a
scalar. Cold-start floor 0.01 is Invariant 1 (numerical stability).
Falls back to [hyperparams.noise_sigma; 4] when fused_ctx unavailable.
Default::default() supplies [0.1; 4]. mod.rs test (line 895) uses
Default::default() unchanged — no explicit field to update.
docs/isv-slots.md updated to reflect SP6 Pearl 3 consumer wired.
Files changed: 4 (experience_kernels.cu, gpu_experience_collector.rs,
training_loop.rs, docs/isv-slots.md). No Pearl 2 or Pearl 5 files touched.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three independent sub-projects (one per Pearl), each an atomic commit,
each running in its own git worktree (sp6-pearl-2/3/5). Pearl 2 expands
compute_adaptive_budgets() to per-branch [f32;4] arrays + trunk-mean
scalars, dispatches branch-correction SAXPY sub-launches. Pearl 3
replaces noise_sigma: f32 with noise_sigma_per_branch: [f32;4] in
ExperienceCollectorConfig and extends add_advantage_noise to per-branch
lookup. Pearl 5 adds 4-branch tau buffer triplets to GpuIqnHead, runs
4 IQN forward passes per step (Approach B), each contributing
iqn_trunk/4 to prevent 4x gradient accumulation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comprehensive review of Layer B (commit 99367b9c6) caught one critical
and two important findings. All three fix in one atomic commit.
Critical: IQL Adam groups 4+5 (IqlHigh, IqlLow) missed the Pearl 4
migration. gpu_iql_trainer.rs::train_value_step still read
self.config.beta1=0.9, self.config.beta2=0.999 at runtime. ISV[230]
(adam_beta1(4)) and ISV[231] (adam_beta1(5)) were populated by the
Layer A producer but unconsumed — partial refactor of the 8-group
Pearl 4 contract. Fix mirrors the Curiosity pattern: 3 new
iql_beta1/beta2/epsilon parameters threaded through the IQL Adam call,
2 ISV reads at each of the 2 train_value_step call sites in
fused_training.rs.
Important: consumer clamp envelopes were wider than the SP4 producer
range (0.5..0.9999 vs producer's 0.85..0.95 for β1, etc.). At
cold-start with ISV=0, this produced β1=0.5 (more aggressive momentum
than the SP4-anchored 0.85 floor). Tightened all Pearl 4 consumer
clamps to match the producer envelope: β1∈[0.85,0.95], β2∈[0.99,0.9995],
ε∈[1e-10,1e-6].
Important: gpu_curiosity_trainer.rs:66-68 left dead ADAM_BETA1/BETA2/
EPS constants after the Pearl 4 migration. Removed.
8/8 Pearl 4 groups now ISV-driven (DqnTrunk, Value, Branches, IQN,
IqlHigh, IqlLow, Attn, Curiosity). cargo check + cargo build + lib
tests all clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SP5 Layer B: all consumers of ISV slots 174..286 migrated in one atomic
commit per feedback_no_partial_refactor.
Pearl 1 (C51 atom span, done prior session):
atoms_update_kernel.cu: reads v_center/v_half from ISV[174+b]/ISV[178+b]
Pearl 1-ext (per-branch num_atoms):
atoms_update_kernel.cu: reads ISV[274+b], rounds to valid {16,32,64},
uses eff_na for softmax/cumsum; output stride still global num_atoms
Pearl 2 (per-branch loss budgets):
fused_training.rs: compute_adaptive_budgets() reads ISV[190..194/198/202]
averaged over 4 branches; Invariant-1 floors 0.05/0.05/0.02/0.02
Pearl 3 (per-branch NoisyNet sigma):
training_loop.rs: ExperienceCollectorConfig.noise_sigma reads
mean(ISV[210..214]) with 0.01 floor; falls back to hyperparams.noise_sigma
when fused_ctx absent
Pearl 4 (per-group Adam beta1/beta2/epsilon):
gpu_dqn_trainer.rs: per-group reads inside launch_adam_update loop via
ISV[226+g], ISV[234+g], ISV[242+g]
gpu_iqn_head.rs: iqn_beta1/beta2/epsilon threaded into
execute_training_pipeline + train_iqn_step_gpu
gpu_attention.rs: attn_beta1/beta2/epsilon threaded into adam_step
gpu_tlob.rs: same as attention (shares ParamGroup::Attn=6)
fused_training.rs: 4 call sites read ISV[226+g..242+g] for IQN+Attn+TLOB
gpu_curiosity_trainer.rs: cur_beta1/beta2/epsilon params added to
launch_adam_step + train_on_collector_buffers
gpu_experience_collector.rs: train_curiosity_gpu threads cur_beta1/2/eps
training_loop.rs: reads ISV[233/241/249] for Curiosity=7 at call site
Pearl 5 (per-branch IQN tau schedule):
gpu_iqn_head.rs: new refresh_taus_from_isv() averages 4 branches per
quantile, applies FIXED_TAUS cold-start floor, re-uploads online_taus/
target_taus/cos_features in-place via upload_f32_via_pinned
fused_training.rs: reads ISV[250..270) before IQN prepare_buffers
Pearl 6 (Kelly cap) + Pearl 8 (trail distance) done in prior session:
trade_physics.cuh, experience_kernels.cu, backtest_env_kernel.cu
Audit doc: dqn-wire-up-audit.md SP5 Layer B entry added.
All 930 passing lib tests still pass; 14 pre-existing failures unchanged.
Compile: SQLX_OFFLINE=true cargo check -p ml --offline clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Combined spec-quality review caught two minor issues in the Pearl 8
commit. Both are mechanical fixes; no behavior change.
1. training_loop.rs:3640 used `let _ = std::mem::ManuallyDrop::new(guard);`
to keep the cudarc StreamGuard alive past the inner let-binding
block — the pattern leaks the guard's borrow bookkeeping for the
lifetime of the function rather than dropping it after the kernel
launch. Replaced with the idiomatic
let (feat_dev_ptr, _guard) = features_buf.device_ptr(stream_ref);
at the same scope as the launch, matching the existing convention
for SP4 producer wire-ups elsewhere in this file. The
_guard binding lets cudarc's borrow drop naturally at end-of-block
after the kernel launch returns.
2. The audit doc's description of test 14 incorrectly claimed
atr_norm was chosen so atr_abs=10.0 and Short/Long=20.0. The
actual test uses atr_norm=0.5 → log_atr=1.0 → atr_abs=e^1≈2.7183
→ Short/Long≈5.4366. Updated the audit doc to match the actual
test values.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-direction trail-stop distance derived from the current bar's ATR.
Short[270] and Long[272] receive 2× denormalized ATR (industry-standard
2×ATR trail); Hold[271] and Flat[273] receive EPS_CLAMP_FLOOR=1.0
(no trail-stop fires for those directions; floor prevents zero).
4 ISV slots [TRAIL_DIST_PER_DIR_BASE=270..274). ATR is read from
features[bar_idx × market_dim + 9] (ATR_NORM column) and denormalized
via the canonical fxcache scheme:
atr_abs = max(0.01, exp(atr_norm × 16 − 7))
Constants 16, 7, 0.01 are Invariant 1 anchors from the existing
fxcache normalization in experience_kernels.cu:2701.
Layer A simplification: Short and Long share the same current-bar ATR
value. The spec implied direction-conditional ATR via per-trade-event
aggregation, but that requires infrastructure not yet in place. The
4-slot ISV layout preserves the consumer contract so a future Layer C
extension can populate truly direction-specific values without
breaking Layer B's check_trailing_stop reads.
producer_step_scratch_buf grew 199 → 203 (1 new constant
SCRATCH_PEARL_8_TRAIL=199). wiener_state_buf stays at 543 (sized at
A1 for entire SP5 block).
StateResetRegistry: 1 new FoldReset entry (sp5_trail_dist_per_dir).
Pearl A sentinel 0 → bootstrap on fold boundary's first launch.
check_trailing_stop consumer migration deferred to Layer B.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Combined spec/quality review caught two minor issues in the Pearl 6
commit. Both are mechanical fixes; no behavior change.
1. Test 12 (pearl_6_kelly_within_fold_ewma_blend) was missing an
explicit assertion for slot 282 (TRADE_VAR_SMOOTH_IDX). The test
setup initialized tvar_i32 and the launcher passed it through the
kernel's parameter slot, but no assert! ever fired against the
resulting ISV value. With n_envs=1, the kernel's
`(kelly_count > 1) ? variance : 0.0f` branch returns 0 (no
cross-env variance possible with 1 env), so EWMA blend yields
0.99 × 0.5 + 0.01 × 0.0 = 0.495. Added the missing assertion to
close the within-fold coverage gap for s==2.
2. pearl_6_kelly_kernel.cu:136 doc comment said the slot computes
"standard deviation of per-env Kelly fractions" but the code
actually computes `ksum_sq / kelly_count` — i.e. the variance
(second moment), not the standard deviation. The slot name
TRADE_VAR_SMOOTH_INDEX correctly indicates variance; the comment
was wrong. Updated comment to match: 'variance of per-env Kelly
fractions' with explicit note that this is the second moment,
NOT std-dev (no sqrtf applied).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `pearl_6_kelly_kernel.cu` (single-block 6-thread kernel reading
portfolio_state directly) to populate ISV[280..286) with Bayesian-prior
Kelly fraction, conviction identity, trade variance, cumulative sample
count (max-semantics), win-rate and loss-rate EMAs. EWMA α=0.01 is an
Invariant 1 structural anchor for cross-fold inertia.
Key design departure from A1-A5: ISV[280..286) are intentionally EXEMPT
from the StateResetRegistry and from apply_pearls/Pearls A+D. portfolio_state
has WindowReset lifecycle (resets at EVERY window boundary, not just fold),
making cross-fold persistence essential. The max()-semantics for sample_count
(s==3) ensure the cumulative count never decreases across any boundary.
Wiener offsets [525..543) that naive formula would produce are intentionally
unused; in-kernel EWMA replaces external wiener bootstrap.
Verification: cargo check -p ml --offline clean (11 pre-existing warnings,
no new). sp5_producer_unit_tests --no-run clean. Two GPU tests (12, 13)
validate EWMA blend and cross-fold persistence.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code-quality review caught that the kernel header described the τ
shift as 'toward the dense tail' but the actual math
(`t = default + skew × SKEW_SHIFT`) shifts τ in the SAME direction
as the skew sign — i.e. toward the LONG (sparse) tail, not the dense
region.
For a left-skewed distribution (long left tail, mode + dense mass on
the right), skew < 0 → τ shifts down toward 0 → IQN samples more of
the LEFT tail (sparse). The intent is risk-aware quantile coverage:
when there's tail mass that the symmetric 5-tuple under-represents,
lean the grid toward that tail. This matches risk-aware IQN where
financial risk modeling cares about downside coverage.
The math is correct and faithful to the plan's formula; only the
prose description was reversed. Replaced the one-liner with a 7-line
explanation that names the actual semantic clearly ('leaning the
quantile grid INTO the asymmetry direction').
Comment-only change. Cubin rebuild clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two minor code-review nits accumulated across A1-A4 reviews; closing
them in a single docs/comment-only commit before Layer A continues.
1. tests/sp5_producer_unit_tests.rs module docstring (A4 review):
the file header still claimed it covered only A1's two kernels
even though A2/A3/A4 had each appended their tests. Updated header
to enumerate all 9 tests across A1-A4 (+ 1 grad_cosine_sim test
landed in 4da6b34d7) so a reader cold to the file can locate each
producer's coverage without scanning the body.
2. gpu_dqn_trainer.rs:2854 stale field comment `[B, TOTAL_ACTIONS(11)]`
(A1 review): comment was left at 11 since the pre-4-direction
layout but the actual slot count is 13 (4 dir + 3 mag + 3 ord + 3
urg). Updated to `[B, TOTAL_ACTIONS(13)]` with the per-branch
breakdown inline so the field declaration matches the SP5 producer
docstrings (e.g. q_branch_stats_kernel comments at line 277, 282).
Comment-only changes; no behavior change. cargo check + cargo test
--no-run both clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code-quality review caught that pearl_4_adam_hparams_kernel had direct
GPU tests but the auxiliary grad_cosine_sim_kernel had none. Tests 7
and 8 launched the hparams kernel with pre-baked cosine inputs,
never exercising the per-group reduction (dot + 2× L2 norm sums) or
the writeback (grad_curr → grad_prev for next step's comparison).
The writeback is load-bearing for cross-step cosine evolution — if
broken, every subsequent step's cosine_sim is computed against a stale
or mis-aligned previous gradient.
Adds Test 9 `grad_cosine_sim_per_group_dot_norm_and_writeback` with
8 analytically-known synthetic group cases:
group 0: curr=prev=e1 → cos=+1, |c|=1
group 1: curr=e1, prev=e2 → cos= 0 (orthogonal)
group 2: curr=e1, prev=-e1 → cos=-1 (antiparallel; raw)
group 3: curr=prev=(3,4,0,0) → cos=+1, |c|=5
group 4: curr=0, prev=e1 → cos= 0, |c|=0 (cold-start curr)
group 5: curr=e1, prev=0 → cos= 0, |c|=1 (Pearl A sentinel)
group 6,7: same as group 0
NO CPU oracle — expected values are unit-vector cosines from the
kernel formula. Writeback verified by reading grad_prev_buf after
the kernel runs and asserting bit-identity with grad_curr_buf for
all 32 elements.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code-quality review caught that the two Pearl 2 unit tests verified each
output (c51, iqn, cql, ens) against its analytical expected value within
1% relative tolerance, but did NOT assert the structural invariant
`c51 + iqn + cql + ens ≈ 1.0` that the kernel maintains by construction
(`ens = max(0, 1 - iqn - c51 - cql)`).
A coefficient typo (e.g. BASE_IQN=0.111 instead of 0.11) would produce
individually-plausible per-component values that all still pass the
relative checks while quietly summing to 0.97 or 1.04. The sum check
catches that class of regression.
Adds `assert!((c51+iqn+cql+ens - 1.0).abs() < 1e-4)` inside both
per-branch loops in the flat-regime and sharp-regime tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-branch C51/IQN/CQL/Ens loss budgets driven by per-branch flatness =
var(Q[b]) / (σ[b]² + EPS_DIV). IQN dominates when flat, C51 yields
proportionally. CQL stays gated by existing regime_stability allocator.
Reads Q_VAR_PER_BRANCH (222..226 from Pearl 1's q_branch_stats) AND
NOISY_SIGMA (210..214 from Pearl 3 — must run AFTER both).
20 ISV slots (BUDGET_C51[190..194), BUDGET_IQN[194..198),
BUDGET_CQL[198..202), BUDGET_ENS[202..206), FLATNESS[206..210)).
StateResetRegistry: 5 new FoldReset entries.
producer_step_scratch_buf grew 111 → 131 (20 new outputs).
wiener_state_buf already at 543 (A1 sized for entire SP5 block).
Loss budget consumer migration deferred to Layer B.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code-quality review caught: pearl_1_atom_kernel.cu computed
`clip_rate = atoms_clip_count[b] / (batch_size × 13)` using the
total-actions=13 sum across all branches. The headroom controller's
TARGET_CLIP_RATE=0.01 is calibrated against the per-branch clip event
rate. Using a denominator 3.25–4.3× larger than the per-branch
denominator made the controller systematically under-responsive — at
actual 1% per-branch clipping it would read ~0.23–0.31% and contract
headroom toward the 2.0 floor instead of holding the target.
Currently masked: `atoms_clip_count` is zero in Layer A (Layer B's
atoms_update_kernel migration is what populates it). Without this fix,
Layer B would inherit a silently-wrong formula that converges to the
floor rather than the target rate.
Fix: plumb `action_counts[4]` ({4, 3, 3, 3}) through the kernel
signature and use `batch_size × action_counts[b]` as the per-branch
denominator. Matches q_branch_stats_kernel's existing parameter pattern.
Test #2 launcher updated to allocate action_counts_buf and pass its
dev_ptr through the new kernel parameter slot. Audit doc entry added
per Invariant 7.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address two code-quality review items on commit 6dcaf1a1c:
1. Convert file-level + Pearl-section comments from `//` to rustdoc
(`//!` module-level + `///` on first constant of each section).
Matches sp4_isv_slots.rs style; SP5 module now `cargo doc`-discoverable
as peer of SP4.
2. Replace spot-check assertions in slot_layout_no_overlaps_and_total_correct
with a HashSet enumerating every slot reachable through every accessor.
Asserts exactly 110 unique slots, min=174, max=285, the 2-slot carve-out
gap (278, 279) absent, and the set equals {174..278} ∪ {280..286}.
Test now actually verifies the no-overlaps invariant its name promised.
Also adds SP5 section to docs/isv-slots.md (Invariant 7 audit-doc update
required by pre-commit hook for cuda_pipeline component changes).
No constant values, accessor signatures, or fingerprint string changed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Critical self-review surfaced 15 issues; user directed fixes:
- "a no cpu path": KEEP host-EMA close-out (rule compliance) — but split
into separate Layer D atomic commit (was mis-scoped as Layer A)
- Pearl 4 kept: 3 concrete risks documented (constant-β proof break,
β2 memory reset destabilization, ε numerical envelope), structural
envelope bounds added, ALPHA_META halved, ε-only fall-back path defined
- Pearl 6 Kelly cross-fold persistence carve-out: separate slot range
280..286, NOT in SP4 fold-reset registry, Invariant 1 architectural exception
- Commit ordering Pearls 1 → 3 → 2 → 4 → 5 → 6 → 8 → 1-ext (resolves
Pearl 2 circular dependency on 1+3)
- Acceptance criteria: correctness gates (must pass) + performance gates
(loosened to "not catastrophically negative", within 2σ of pre-SP5)
- Pearl 7 timing: explicitly Layer C step 4, post-Layer-B + 3-seed validation
- Pearl 8 enumeration: 4 slots (TRAIL_DIST_PER_DIR per direction)
- Pearl 9 collapsed: 0 slots (Thompson achieved via Pearl 1's atom adaptation)
- Total slot count corrected: 110 (was 120-128 inconsistent)
Layer structure: A (additive, 8 commits) → B (atomic, 11 consumers) → C
(validation + Pearl 7 investigation) → D (host-EMA close-out, separate
atomic commit). Layer D split off from A's "close-out" because PnL
aggregation pipeline migration is its own architectural concern.
User final review pending before invoking writing-plans skill.
Per user direction:
Q1 (Layer A granularity): per-pearl commits (~9 commits, decision c)
Q2 (Pearl 7 timing): investigate-only in SP5; fix in follow-up
if Bin(2,0.5) persists post-Pearls-1-3 (decision a)
Q3 (Pearl 4 Adam β): include as designed; accept theoretical risk
with Pearls A+D + EPS_CLAMP_FLOOR mitigation;
Layer C smoke monitors for destabilization;
fall-back to ε-only if observed (decision a)
Spec section updated:
- Layer A description: per-pearl commit structure
- Pearl 7 framed as investigation-only with conditional follow-up
- Pearl 4 documents theoretical caveat + mitigation + fallback
User final review pending before invoking writing-plans skill.
SP5 design covers every known adaptive-parameter deferral in the DQN
training loop in a single coherent project. After SP5: zero hardcoded
multipliers, every adaptive value ISV-driven via Pearls A+D.
9 pearls + 1 sweep close-out + 1 validation milestone:
1-3. Per-branch atom span / loss budget / NoisyNet σ (52 slots)
4. Per-group Adam β1/β2/ε ISV-driven (24 slots)
5. Per-branch IQN τ schedule (20 slots)
6. Kelly cap signal-driven floors (6 slots)
7. dist_q/h/f Bin(2,0.5) audit + action_select fix (0-8 slots)
8. Trail stop signal-driven thresholds (6-8 slots)
9. Thompson direction-branch temperature (4 slots)
1-ext. Per-branch C51 num_atoms (4 slots)
Layer A close-out: 5 host-EMA host→GPU migrations
Validation: 3-seed × 50-epoch acceptance gate
Total: 120-128 new ISV slots, ~5000-7500 LOC, 11-13 producer kernels,
~12 consumer migrations.
Layer A (additive infrastructure, ~15 commits) → Layer B (atomic
consumer migration, single coordinated commit) → Layer C (validation +
cleanup). Mirrors SP4's layer pattern.
Triggering data: train-multi-seed-cv2mw 50-epoch L40S baseline
(terminated F0 ep10) revealed magnitude head Q-flatness, eval collapse,
and frozen action distributions. Plus all SP4 close-out + sweep
deferrals folded in per user direction "no deferrals — make a single
plan based on ALL findings".
Spec at:
docs/superpowers/specs/2026-05-01-sp5-magnitude-differentiation-and-eval-collapse-design.md
User review pending before invoking writing-plans skill.
Adds intent_dist_q/h/f alongside eval_dist_q/h/f to separate policy-
learning quality from Kelly-enforcement reality. Per memory pearl
project_magnitude_eval_collapse_kelly_capped.md: EVAL_DIST Quarter
dominance is a downstream artefact of Kelly cap × warmup_floor ×
safety_multiplier math, NOT a policy-learning failure. The intent
metric exposes the policy's pre-Kelly-cap chosen mag bucket so
operators can distinguish "policy isn't learning Full" from "Kelly
cap is suppressing Full" — the latter being an operationally-correct
state during cold-start positions.
Pure observability: no Kelly-math tweaks, no new tuned constants, no
reward-bias mechanisms (all explicitly forbidden by the memory pearl).
The intent_mag bucket comes straight from the factored action's
mag_idx (0/1/2) which already maps 1:1 to Quarter/Half/Full buckets.
The pre-cap mag_idx is already captured by the action-select kernel
into intent_mag_buf and exposed via the trainer's pre-existing
last_eval_intent_magnitude_dist host field — this commit only wires
that signal into HEALTH_DIAG.
Wired through (one coordinated commit per feedback_no_partial_refactor):
- health_diag.rs: HealthDiagSnapshot gains intent_dist_q/h/f fields
adjacent to eval_dist_*; size assertion bumped 147 → 150 fields.
- health_diag_kernel.cu: 3 new WORD_INTENT_DIST_* slots at [77..80);
every downstream WORD_* shifted by +3 in lockstep; WORD_TOTAL +
static_assert bumped 147 → 150. (The slots are reserved identically
to WORD_EVAL_DIST_* — both currently inert; HEALTH_DIAG GPU port
Phases 2/3 will populate them in lockstep with their sibling slots.)
- training_loop.rs: new adjacent tracing::info!() emitting
"intent_dist [iq=... ih=... if=...]" from the existing host-side
last_eval_intent_magnitude_dist field, immediately after the big
HEALTH_DIAG line containing eval_dist [eq=... eh=... ef=...].
- docs/dqn-wire-up-audit.md: new top-of-file entry per Invariant 7.
Behavior: zero change. Only adds observability.
Verification:
- cargo check -p ml --offline: clean.
- cargo test -p ml --lib --offline -- health_diag: 3/3 pass.
- cargo test -p ml --test sp4_producer_unit_tests --release
--ignored: 16/16 GPU tests pass.
Closes#212.
Per-step host-side EMA loop at gpu_dqn_trainer.rs:4671 over 6 mapped-
pinned homeostatic-target slots was a feedback_no_cpu_compute_strict
violation discovered during the sweep audit (commit 6a6b58aec) but
deferred for scope. Sweep audit grid site #9.
Migrated:
- New calibrate_homeostatic_kernel.cu — single-block, six threads
(one thread per homeostatic slot). Reads host-passed `readiness`
scalar (already-clamped IQN gauge from `iqn_readiness` shadow field,
bit-for-bit match of the deleted host clamp), observations from
`homeostatic_obs_dev_ptr`, applies adaptive α
`0.3 × (1 - readiness) + 0.01 × readiness` to targets[k] for k=1..5;
thread 0 forces the Q-mean invariant `targets[0] = 0.0` exactly as
the deleted host post-loop assignment did. __threadfence_system()
after writes for PCIe-visibility to homeostatic_kernel's dev_ptr reads.
- build.rs cubin registration and trainer-struct wiring (cubin static,
field, struct constructor, cubin load) mirror the C2/C3/C4 pattern
from the prior sweep commits.
- Host-side `for k in 0..HOMEOSTATIC_N_OBS` loop in
`calibrate_homeostatic_targets` replaced with a single
`launch_calibrate_homeostatic` kernel launch; chained on the
trainer's stream so it remains graph-capture-compatible.
Preserved:
- Same α formula, same Q-mean=0 invariant, same call ordering.
- Mapped-pinned target buffer retained — homeostatic_kernel still
reads via homeostatic_targets_dev_ptr unchanged.
- No cold-start sentinel: constructor pre-initialises targets to
`[0.0, 0.85, 0.1, 0.0, 1.0, 0.5]` (gpu_dqn_trainer.rs:14583-14588)
so the first EMA call blends defaults with the first observation,
same algebraic shape the deleted host loop relied on.
- State-reset registry unchanged — deleted host loop had no fold reset
(per-call EMA only); GPU port preserves identical per-call semantics.
cargo check clean. SP4 + state_reset_registry lib tests pass (11/11).
16/16 SP4 producer GPU tests pass on RTX 3050 Ti. No behavior change —
pure architectural fix.
Refs: feedback_no_cpu_compute_strict sweep audit grid site #9.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>