feat(sp21): T2.2 Phase 8 — signal-drive E2+E5 controller gains via val_sharpe_std (atomic)

Eliminates remaining hardcoded controller GAINS in enrichment.rs per
pearl_controller_anchors_isv_driven. Both E2 (compute_adaptive_epsilon)
and E5 (compute_agreement_threshold) now derive gain magnitudes
from val_sharpe_std = √ISV[VAL_SHARPE_VAR_EMA_INDEX=351] — same
signal source as the early-stopping pipeline. Phase 2 already
signal-drove the anchors; this commit closes the GAIN half.

NO new ISV slots. NO kernel changes. NO ISV_TOTAL_DIM bump. Pure
value-driven refactor of two enrichment functions.

E2 transformation:
- Bracket anchors 2.0/0.5/-0.5 → 2.0×std / 0.5×std / -0.5×std
- Multiplicative gains 0.8/0.95/1.2 → (1 ± gain_mag) and (1 - 0.5×gain_mag)
- gain_mag = val_sharpe_std.clamp(0.05, 0.30) (Invariant 1 carve-out)
- Cold-start (var_ema==0) → pass-through

E5 transformation:
- Tighten step 0.9 → (1 - gain_mag)
- Loosen step 1.1 → (1 + gain_mag)
- Same gain_mag formula as E2 (consistency)

Invariant 1 carve-outs explicitly retained (project-wide priors):
- [0.05, 0.30] gain_mag stability clamp (mirrors Wiener-α floor)
- [0.85, 0.98] E3 gamma support range (trading-frequency prior)
- [0.5, 2.0] E4 per-branch LR multiplier (collapse/divergence guard)

Files changed:
- crates/ml/src/trainers/dqn/trainer/enrichment.rs: E2 takes new
  val_sharpe_var_ema arg; both E2 and E5 derive gains from
  val_sharpe_std; run_enrichments call-site arg added
- docs/dqn-wire-up-audit.md: 2026-05-11 audit entry

Verification (passing):
- cargo check -p ml --tests --features cuda: 0 errors
- cargo test -p ml --lib sp21_isv_slots: 3/3
- sp20_aggregate_inputs_test: 12/12
- sp20_phase1_4_wireup_test: 2/2
- sp20_emas_compute_test: 4/4
- sp20_controllers_compute_test: 7/7
- sp21_per_trade_predicted_q_test: 3/3
Total: 34 tests, 0 failures.

SP21 T2.2 cascade COMPLETE — all 8 atomic phases landed (1.5, 2, 3,
4, 4.5, 5+6, 7, 8). Remaining future work out of T2.2 scope:
- Phase 6.5 (deferred): true E7 hindsight synthetic injection
- Phase 7.5 (deferred): true E8 per-segment PER sampling
Next operational step: dispatch L40S smoke training run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-10 23:58:59 +02:00
parent 34d19955ff
commit 1d2dd38a10
2 changed files with 191 additions and 17 deletions

View File

@@ -123,15 +123,41 @@ fn compute_q_correction(trades: &[EvalTrade]) -> f32 {
}
/// E2: Adaptive epsilon — performance-driven exploration rate.
fn compute_adaptive_epsilon(current: f32, val_sharpe: f32) -> f32 {
let scale = if val_sharpe > 2.0 {
0.8
} else if val_sharpe > 0.5 {
0.95
} else if val_sharpe < -0.5 {
1.2
///
/// **Phase 8 refactor (2026-05-11)** — both ANCHORS (val_sharpe
/// brackets) and GAINS (multiplicative step sizes) are now ISV-
/// driven from `val_sharpe_var_ema` (slot 351). The brackets are
/// scaled to multiples of `val_sharpe_std = √var_ema` so the
/// "excellent / good / poor" thresholds adapt to the run's noise
/// floor instead of using static `2.0 / 0.5 / -0.5` constants.
/// The gain magnitude scales with `val_sharpe_std` too — the
/// controller responds more aggressively when val Sharpe is
/// noisier (sharp adjustments) and gently when stable.
/// Cold-start (`var_ema == 0`): pass-through (no-op) per
/// `pearl_first_observation_bootstrap`. The `[0.05, 0.30]` clamp
/// on gain magnitude is an Invariant 1 carve-out — single-step
/// gains outside that range produce instability; structural
/// floor/cap mirrors `pearl_wiener_alpha_floor_for_nonstationary`.
fn compute_adaptive_epsilon(current: f32, val_sharpe: f32, val_sharpe_var_ema: f32) -> f32 {
let val_sharpe_std = val_sharpe_var_ema.max(0.0).sqrt();
if val_sharpe_std <= 1e-6 {
// Cold-start: no signal yet, return current unchanged.
return current.clamp(0.01, 0.15);
}
// ISV-driven anchors: brackets scale with val_sharpe noise floor.
let high_anchor = 2.0 * val_sharpe_std;
let good_anchor = 0.5 * val_sharpe_std;
let poor_anchor = -0.5 * val_sharpe_std;
// ISV-driven gain magnitude (Invariant 1 stability carve-out).
let gain_mag = val_sharpe_std.clamp(0.05, 0.30);
let scale = if val_sharpe > high_anchor {
1.0 - gain_mag // excellent → reduce exploration
} else if val_sharpe > good_anchor {
1.0 - 0.5 * gain_mag // good → slight reduction
} else if val_sharpe < poor_anchor {
1.0 + gain_mag // poor → increase exploration
} else {
1.0
1.0 // mediocre → unchanged
};
(current * scale).clamp(0.01, 0.15)
}
@@ -250,15 +276,21 @@ fn compute_agreement_threshold(
// ISV-driven anchor: spread > 1σ of val_sharpe noise = significant
// separation between high-conviction and low-conviction trades →
// tighten threshold (trust agreement more). Spread within the
// noise floor → loosen threshold toward unity (don't over-trust
// an agreement signal that isn't producing separation). The
// tighten/loosen STEP sizes (0.9/1.1) are controller gains, not
// anchors — separate parameter from the ISV-driven decision
// boundary.
// noise floor → loosen threshold toward unity.
//
// **Phase 8 refactor (2026-05-11)** — tighten/loosen GAINS are
// now ISV-driven too: `gain_mag = val_sharpe_std.clamp(0.05,
// 0.30)` replaces the prior hardcoded 0.9 / 1.1 multipliers.
// Controller responds more aggressively when val Sharpe is
// noisier (large σ → ~30% adjustment) and gently when stable
// (small σ → ~5% adjustment). Matches E2's adaptive-gain
// pattern; same Invariant 1 carve-out justification for the
// `[0.05, 0.30]` stability clamp.
let gain_mag = val_sharpe_std.clamp(0.05, 0.30);
let new = if spread > val_sharpe_std {
current * 0.9
current * (1.0 - gain_mag) // tighten
} else {
current * 1.1
current * (1.0 + gain_mag) // loosen
};
new.clamp(0.01, 10.0)
}
@@ -442,8 +474,9 @@ pub(crate) fn run_enrichments(
// E1: Q-value bias correction
let q_correction = compute_q_correction(eval_trades);
// E2: Adaptive epsilon
let epsilon = compute_adaptive_epsilon(state.epsilon, val_sharpe);
// E2: Adaptive epsilon (SP21 Phase 8: gains+anchors signal-driven
// via val_sharpe_var_ema, same ISV slot E5 uses for its anchor).
let epsilon = compute_adaptive_epsilon(state.epsilon, val_sharpe, val_sharpe_var_ema);
state.epsilon = epsilon;
// E3: Dynamic gamma