spec(sp11): fix all 13 review issues — straight-up implementation

Critical bugs fixed:
1. Z-score formula: was mean(Δ)/mean(|Δ|), bounded to [-1,+1] — sigmoid
   never saturated, controller stuck in [0.27, 0.73]. Now true Z-score:
   delta_ema / sqrt(delta_var_ema) with two-pass var. Renamed canary
   slot 351 from VAL_SHARPE_STD_EMA to VAL_SHARPE_VAR_EMA.
2. Component weight renormalization added — Σweights = 1 enforced
   post-floor so PopArt's reward-magnitude EMA stays uncontaminated.
3. SABOTEUR_MIN bound violation — engagement-floor (0.1) was silently
   pulling output below 0.5 stated minimum. Added post-multiplication
   clamp to [SABOTEUR_MIN, SABOTEUR_MAX].
4. ratios[] undeclared — now __shared__ float ratios[6] block-loaded
   once from ISV.
5. §3.6 slot count off-by-five (15 → 20). All 20 slots get FoldReset
   entries; cold-start window behavior specified explicitly (no NaN path).

Architectural fixes:
6. Q-overconfidence concern addressed in scope — controller's
   REWARD_CF_WEIGHT_INDEX covers CQL conservatism. No SP11′ deferral;
   if T10 fails, extend controller outputs (e.g., target-update τ).
7. Curiosity recompute-at-replay specified (§3.5.1) — replay buffer
   stores base reward only; curiosity added per-tuple at replay time
   against current visit-count and current ISV. Eliminates stale-signal
   replay contamination that would worsen ep1-overfitting.

Smaller fixes:
8. Novelty signal specified — SimHash 42×16 projection → 16-bit code,
   1M-slot per-bucket count table, 1/sqrt(1+count). Hash table lives
   in state-reset registry (cleared at fold boundary).
9. Saboteur engagement operationally defined — per-bar
   |reward_with_saboteur − reward_without_saboteur| > EPS_ENGAGEMENT,
   block tree-reduce, EMA'd. EPS_ENGAGEMENT = 0.01 × PnL_EMA.
10. Duplicate sentence in §7 component-starvation paragraph removed.
11. Validation criteria strengthened (§9): aggregation across 9
    trajectories specified; primary metric = median peak-epoch ≥ 10
    (baseline median peak-epoch = 1); secondary = drop-from-peak ≤ 5%
    by ep20; tertiary = ep20 mean ≥ baseline ep1 mean. §9.3 fix-forward
    response codified — no rollback.
12. Phases split per project pattern — Layer A additive (3 commits:
    slots, canaries, controller), Layer B atomic consumer migration
    (1 commit, including replay-time curiosity), Layer C validation +
    close-out. No falsification gate; smoke is validation only.
13. Pearls A+D applied to controller outputs (§3.4.1) — chained
    apply_pearls_ad_kernel after controller writes scratch, smoothed
    values land in ISV. Consumers read smoothed slots.

Constants surviving (Invariant-1 anchors only, all rate-not-regime):
EPS_DIV, WEIGHT_HARD_FLOOR, SABOTEUR_MIN/MAX, CURIOSITY_PERMANENT_FRACTION,
CURIOSITY_BOUND_FRACTION, WEIGHT_FLOOR_FRACTION, ENGAGEMENT_FLOOR.

Spec is now full straight-up implementation; smoke validates Layer A
infrastructure and Layer B consumers, T10 validates SP11 success metric.
~1550 LOC across 3 commits in Layer A + 1 atomic commit in Layer B +
close-out in Layer C.
This commit is contained in:
jgrusewski
2026-05-04 00:20:25 +02:00
parent d49fbbe4e2
commit 9395b983cc

View File

@@ -52,21 +52,45 @@ The reward components (`popart`, `cf`, `trail_r`, `micro`, `opp_cost`,
decays on a hardcoded 0.995/epoch schedule in `gpu_experience_collector.rs`).
None adapt to whether training is improving, stagnating, or regressing.
### 2.1 Design awareness — Q-overconfidence is also in scope
The "Q grows while val drops" signature can also be driven by
**TD-target overconfidence**: optimistic bootstrap targets pull the
value function toward higher magnitudes regardless of policy quality.
SP11's CQL component-weight slot (`REWARD_CF_WEIGHT_INDEX`) explicitly
covers this case: when `improving_z < 0` (regressing), the
controller diversifies weights toward under-used components, which
includes raising CQL conservatism if the grad-ratio canary shows CQL
under-firing. Because the controller's input space includes the very
shaping multipliers that govern Q-target conservatism, SP11 *is* the
fix for both reward-shaping and conservatism in one architecture.
No separate SP11 is needed. If T10 (§9) shows residual decline after
SP11, the response is to extend the controller's outputs further (e.g.,
target-update τ as a new ISV slot), not to pause SP11.
## 3. Architecture (refined: every input ISV-driven)
### 3.1 Z-score-driven adaptation (no hardcoded thresholds)
The "improving / stagnant / regressing" classification uses a Z-score, not a
hardcoded threshold:
The "improving / stagnant / regressing" classification uses a **true Z-score**
(mean Δ divided by std of residuals around the mean — *not* mean over
magnitude, which would bound z to [-1, +1] and prevent sigmoid saturation,
collapsing the controller's effective range to [0.27, 0.73]):
```
improvement_z = val_sharpe_delta_ema / max(val_sharpe_std_ema, EPS_DIV)
delta = val_sharpe[t] val_sharpe[t-1]
delta_ema = EMA(delta) ← running mean of Δ
delta_var_ema = EMA( (delta delta_ema)² ) ← variance around the trend
improvement_z = delta_ema / max( sqrt(delta_var_ema), EPS_DIV )
```
Where:
- `val_sharpe_delta_ema` = EMA of (val_sharpe[t] val_sharpe[t-1])
- `val_sharpe_std_ema` = EMA of |val_sharpe_delta| (running noise estimate)
- Both Pearl D Wiener-α-smoothed; both have Pearl A sentinel-bootstrap
`improvement_z` is unbounded so `sigmoid(z)` can saturate near 0 or 1 in
regimes that warrant it. Both EMAs are Pearl D Wiener-α-smoothed and
Pearl A sentinel-bootstrapped (first observation replaces sentinel
directly). The variance EMA is computed inside the canary kernel in a
two-pass form: first update `delta_ema`, then accumulate the squared
deviation against the *updated* mean (corrects for the EMA-of-EMA bias).
`improvement_z`; the controller's outputs are continuous functions of
`z`, not threshold-gated:
@@ -97,43 +121,91 @@ Where:
| Slot | Source | Pearl pattern |
|---|---|---|
| `VAL_SHARPE_DELTA_EMA_INDEX` | `Δsharpe[t] = sharpe[t]sharpe[t-1]`, Pearl D smoothed | A+D |
| `VAL_SHARPE_STD_EMA_INDEX` | EMA of `|Δsharpe|` (noise estimate) | A+D |
| `REWARD_COMPONENT_GRAD_RATIO_BASE` | 6 slots, per-component grad-EMA / total-grad-EMA | A+D |
| `SABOTEUR_ENGAGEMENT_RATE_INDEX` | Fraction of episodes where saboteur params shifted Q by > EPS | Pearl C engagement counter |
| `VAL_SHARPE_VAR_EMA_INDEX` | EMA of `(Δsharpe delta_ema)²` (true variance estimator) | A+D |
| `REWARD_COMPONENT_GRAD_RATIO_BASE` | **6 slots** [base..base+6), per-component grad-EMA / total-grad-EMA | A+D |
| `SABOTEUR_ENGAGEMENT_RATE_INDEX` | Per-bar saboteur-driven Q-shift hit rate (see §3.3.1 for operational definition) | Pearl C engagement counter |
| `PNL_REWARD_MAGNITUDE_EMA_INDEX` | `|popart_reward|` EMA — bounds curiosity proportionally | A+D |
#### 3.3.1 Operational definitions
**Saboteur engagement** (Foxhunt has bars, not episodes): inside
`gpu_experience_collector`, the saboteur perturbs fill price and slippage
per-bar. Engagement is the fraction of bars where the perturbation
*actually changed the realized reward by ≥ EPS_ENGAGEMENT*:
```cuda
bool engaged = fabsf(reward_with_saboteur - reward_without_saboteur) > EPS_ENGAGEMENT;
```
The "without saboteur" reward is computed cheaply by un-applying the
perturbation (price + perturbation_offset → price). This is a per-bar
boolean reduced via block tree-reduce (no atomicAdd, per
`feedback_no_atomicadd`) into a per-step engagement count, then divided
by batch size and EMA'd. EPS_ENGAGEMENT = `0.01 × isv[PNL_REWARD_MAGNITUDE_EMA]`
(Invariant-1 fraction of PnL signal scale).
**Per-component grad ratio**: each loss kernel already accumulates
per-component gradient magnitudes for the SP4 grad-balancer. Reuse those
6 EMAs; ratio = `grad_ema[c] / max(sum(grad_ema), EPS_DIV)`.
### 3.4 Producer kernel: `reward_subsystem_controller`
Single block, 10 threads (one per output). Reads all 5 canaries, computes:
```cuda
const float EPS_DIV = 1e-6f;
const float SABOTEUR_MIN = 0.5f; // Invariant-1 rate limiter
const float SABOTEUR_MAX = 2.0f; // Invariant-1 rate limiter
const float WEIGHT_HARD_FLOOR = 0.01f; // Invariant-1 numerical floor (prevents zero)
const float SABOTEUR_MIN = 0.5f; // Invariant-1 rate limiter
const float SABOTEUR_MAX = 2.0f; // Invariant-1 rate limiter
const float WEIGHT_HARD_FLOOR = 0.01f; // Invariant-1 numerical floor
const float CURIOSITY_PERMANENT_FRACTION = 0.2f; // Invariant-1 floor fraction
const float ENGAGEMENT_FLOOR = 0.1f; // Invariant-1 fraction
const float CURIOSITY_BOUND_FRACTION = 0.3f; // Invariant-1 fraction of PnL EMA
const float WEIGHT_FLOOR_FRACTION = 0.5f; // Invariant-1 fraction of min grad ratio
// Z-score (signal-relative; no hardcoded threshold)
float dz = isv[VAL_SHARPE_DELTA_EMA] / fmaxf(isv[VAL_SHARPE_STD_EMA], EPS_DIV);
// Block-load all 6 ratios into shared mem ONCE (avoids re-reading ISV).
__shared__ float ratios[6];
if (threadIdx.x < 6) {
ratios[threadIdx.x] = isv[REWARD_COMPONENT_GRAD_RATIO_BASE + threadIdx.x];
}
__syncthreads();
// Sigmoid maps z continuously to [0, 1]; no thresholds
// True Z-score: mean Δ over std-of-residuals, NOT mean Δ over mean |Δ|.
// (Latter would be bounded to [-1,+1] and never let sigmoid saturate.)
float delta_var = isv[VAL_SHARPE_VAR_EMA_INDEX];
float dz = isv[VAL_SHARPE_DELTA_EMA_INDEX]
/ fmaxf( sqrtf(delta_var), EPS_DIV );
// Sigmoid maps z continuously to [0, 1]
float improving = 1.0f / (1.0f + expf(-dz)); // 0=regressing, 0.5=stagnant, 1=improving
float stagnant_or_worse = 1.0f - improving;
// Per-component weights: blend toward (a) high-grad-ratio winners when
// improving, (b) under-used components when stagnant, (c) floor when
// regressing. Floor itself signal-driven.
float floor = isv[REWARD_WEIGHT_FLOOR_INDEX]; // adaptive (see below)
// Per-component weight FLOOR (signal-driven from worst-suppressed component)
float min_grad_ratio = ratios[0];
for (int c = 1; c < 6; c++) min_grad_ratio = fminf(min_grad_ratio, ratios[c]);
float adaptive_floor = fmaxf(WEIGHT_HARD_FLOOR, WEIGHT_FLOOR_FRACTION * min_grad_ratio);
scratch[scratch_weight_floor] = adaptive_floor;
// Per-component weights — blend pre-floor, then enforce floor, then RENORM.
// Renormalization is critical: PopArt's reward-magnitude EMA assumes a
// consistent reward-scale; sum(weights) drifting > 1 would inflate the
// running |reward| and contaminate the popart target every fold.
float blends[6];
float blend_sum = 0.0f;
for (int c = 0; c < 6; c++) {
float ratio = isv[REWARD_COMPONENT_GRAD_RATIO_BASE + c];
float winner_weight = ratio; // exploit
float diversifier_weight = (1.0f - ratio) / 5.0f; // explore
float blend = improving * winner_weight + stagnant_or_worse * diversifier_weight;
float weight = fmaxf(floor, fminf(1.0f, blend));
scratch[scratch_weight_base + c] = weight;
float winner_weight = ratios[c]; // exploit
float diversifier_weight = (1.0f - ratios[c]) / 5.0f; // explore (sums to 1ratios[c]·5/5 across c, see note)
float w = improving * winner_weight + stagnant_or_worse * diversifier_weight;
w = fmaxf(adaptive_floor, fminf(1.0f, w));
blends[c] = w;
blend_sum += w;
}
// Renormalize so Σweights = 1 exactly. EPS_DIV guards the (impossible)
// all-floor-zero case.
float inv_sum = 1.0f / fmaxf(blend_sum, EPS_DIV);
for (int c = 0; c < 6; c++) {
scratch[scratch_weight_base + c] = blends[c] * inv_sum;
}
// Curiosity bound = fraction of recent PnL magnitude (signal-relative)
float curiosity_bound = isv[PNL_REWARD_MAGNITUDE_EMA] * 0.3f; // 0.3 is Invariant-1 fraction
float curiosity_bound = isv[PNL_REWARD_MAGNITUDE_EMA_INDEX] * CURIOSITY_BOUND_FRACTION;
scratch[scratch_curiosity_bound] = curiosity_bound;
// Curiosity pressure: ALWAYS-ON exploration signal scaled by stagnation.
@@ -141,35 +213,53 @@ scratch[scratch_curiosity_bound] = curiosity_bound;
// When stagnating: rises toward curiosity_bound to break out of fixed point.
// Permanent floor pattern per pearl_blend_formulas_must_have_permanent_floor —
// max() form, not blend, so curiosity never zeros even when policy is winning.
float curiosity_floor = CURIOSITY_PERMANENT_FRACTION * curiosity_bound; // 0.2 = Invariant-1 fraction
float curiosity_floor = CURIOSITY_PERMANENT_FRACTION * curiosity_bound;
float curiosity_dynamic = stagnant_or_worse * curiosity_bound;
float curiosity_pressure = fmaxf(curiosity_dynamic, curiosity_floor);
scratch[scratch_curiosity_pressure] = curiosity_pressure;
// Saboteur intensity: continuous mapping of z → [SABOTEUR_MIN, SABOTEUR_MAX]
// adjusted by engagement rate (per pearl_engagement_rate_self_correction).
// If saboteur is rarely shifting Q (engagement → 0), back off.
// modulated by engagement rate (per pearl_engagement_rate_self_correction).
// Compute the z-term inside the bound, modulate by engagement, and re-clamp
// to [SABOTEUR_MIN, SABOTEUR_MAX]. Without the post-clamp the engagement
// floor (0.1) would silently pull the output below SABOTEUR_MIN.
float engagement = isv[SABOTEUR_ENGAGEMENT_RATE_INDEX];
float intensity_z_term = SABOTEUR_MIN + (SABOTEUR_MAX - SABOTEUR_MIN) * improving;
float saboteur_intensity = intensity_z_term * fmaxf(engagement, 0.1f); // 0.1 is engagement floor
float modulated = intensity_z_term * fmaxf(engagement, ENGAGEMENT_FLOOR);
float saboteur_intensity = fmaxf(SABOTEUR_MIN, fminf(SABOTEUR_MAX, modulated));
scratch[scratch_saboteur_intensity] = saboteur_intensity;
// Per-component weight floor: signal-driven from worst-suppressed component's
// grad-EMA. Idea: if any component has been consistently below mean grad,
// its floor rises so it gets a chance to contribute.
float min_grad_ratio = ratios[0];
for (int c = 1; c < 6; c++) min_grad_ratio = fminf(min_grad_ratio, ratios[c]);
float adaptive_floor = fmaxf(WEIGHT_HARD_FLOOR, 0.5f * min_grad_ratio); // 0.5 = Invariant-1 fraction
scratch[scratch_weight_floor] = adaptive_floor;
```
The constants `0.3f` (curiosity fraction of PnL), `0.5f` (floor fraction
of min grad ratio), `0.2f` (CURIOSITY_PERMANENT_FRACTION — exploration
floor as fraction of curiosity_bound), and `0.1f` (engagement floor) are
Invariant-1 fractions: they bound rates, not regimes, and remain valid
across all regimes. The thresholds themselves (what counts as "improving",
"stagnant", "regressing") are entirely encoded in the Z-score and sigmoid
— no hardcoded threshold survives.
#### 3.4.1 Output smoothing (Pearls A+D applied to controller outputs)
The 10 controller outputs are written to `scratch_*` first, then a chained
`apply_pearls_ad_kernel` launch smooths each output into its ISV slot.
This prevents the controller from oscillating outputs at every training
step (raw `improving` is responsive but jittery). Pearl A handles the
fold-boundary cold-start; Pearl D's Wiener-α adapts smoothing strength to
each output's own noise level (curiosity may be smoother than weights, etc.)
This means the consumer kernels read the **smoothed** controller outputs
from ISV, not the raw scratch values.
#### 3.4.2 Surviving constants (Invariant-1 anchors only)
All seven hardcoded constants in the controller bound *rates*, not *regimes*,
and remain valid across regime shifts:
| Const | Value | Role |
|---|---|---|
| `EPS_DIV` | `1e-6f` | divide-by-zero protection |
| `WEIGHT_HARD_FLOOR` | `0.01f` | absolute weight floor — prevents zero |
| `SABOTEUR_MIN` / `MAX` | `0.5f` / `2.0f` | curriculum rate limiter |
| `CURIOSITY_PERMANENT_FRACTION` | `0.2f` | exploration floor as fraction of `curiosity_bound` |
| `CURIOSITY_BOUND_FRACTION` | `0.3f` | curiosity ceiling as fraction of PnL EMA |
| `WEIGHT_FLOOR_FRACTION` | `0.5f` | adaptive floor as fraction of min grad ratio |
| `ENGAGEMENT_FLOOR` | `0.1f` | saboteur engagement floor (pre-bound) |
The thresholds that classify regime ("improving / stagnant / regressing")
are entirely encoded in the Z-score and sigmoid — no hardcoded threshold
survives at the regime layer.
### 3.5 Consumer migrations
@@ -179,12 +269,46 @@ across all regimes. The thresholds themselves (what counts as "improving",
- `c51_loss_kernel.cu:789` — same migration
- Other reward shaping multipliers identified during implementation audit
**Curiosity bonus** (NEW reward dimension):
- New term in reward computation: `r_total += isv[CURIOSITY_PRESSURE_INDEX] *
novelty_signal(state, action)`
- `novelty_signal` is the simplest workable form: state-action visit count
EMA → 1 / sqrt(count) (RND-style optional later)
- Bounded by `CURIOSITY_BOUND_INDEX` in the producer kernel
**Curiosity bonus** (NEW reward dimension, applied at *loss-compute* time
not at experience-collect time — see §3.5.1):
- The replay buffer continues to store the **base reward** only (no
curiosity baked in). Curiosity is added to `r` at the moment a tuple is
drawn for replay, recomputed against the *current* visit-count table
and *current* `CURIOSITY_PRESSURE_INDEX`.
- Recompute formula: `r_with_curiosity = r_base + isv[CURIOSITY_PRESSURE_INDEX]
* novelty_signal(state, action)`
- Bounded above by `CURIOSITY_BOUND_INDEX` in the producer kernel.
#### 3.5.1 Why recompute-at-replay (not store-at-collect)
If we store curiosity-augmented r in replay, old tuples retain the
curiosity bonus they had *at the moment they were collected*, even though
the policy now visits those state-actions frequently (curiosity should be
near zero). Replaying those tuples then teaches the policy to revisit the
fixed point we're trying to escape — making the ep1-overfitting pathology
worse, not better. Recomputing at replay binds curiosity to the *current*
exploration deficit, which is the only signal that matters.
The cost is one extra hash-lookup + multiply per replayed tuple. Existing
replay sampling kernel adds two ops; no extra kernel launch.
#### 3.5.2 Novelty signal (state-action visit count via locality-sensitive hash)
The state space is continuous 42-dim. Use a SimHash-style discretization:
- Project state through a fixed (untrained) random matrix `W ∈ R^{42×16}`
with sign-quantized output: `code = sign(state @ W)` → 16-bit hash.
- Bucket = `(action_id, code)` — i.e., 108 actions × 65,536 codes = ~7M buckets.
- Visit count = atomic-free per-bucket EMA in a hash table sized 1M slots
(with linear-probe collisions tolerated; collisions = false novelty
underestimates, which is the safe direction).
- `novelty_signal = 1 / sqrt(1 + count)`. Range [0, 1].
- Hash table is per-fold (cleared at fold boundary via state-reset registry).
- The 42×16 projection matrix is fixed at trainer init from a deterministic
seed (so different runs with same seed get same hash function).
This is a cheap, GPU-friendly stand-in for RND or count-based bonuses. RND
or full state-action density estimation can replace it later if smoke
shows the hash discretization is too coarse.
**Saboteur intensity** (modulator on existing saboteur scale):
- `gpu_experience_collector.rs:3328` — `let ps =
@@ -195,15 +319,31 @@ across all regimes. The thresholds themselves (what counts as "improving",
### 3.6 Reset semantics
All 15 new slots: FoldReset (sentinel 0, Pearl A bootstraps from first
observation). Cross-fold persistence is wrong here because the
"improvement signal" should reset at each fold's cold-start.
All **20** new slots get FoldReset entries (sentinel 0, Pearl A bootstraps
from first observation). Cross-fold persistence is wrong for every slot
in this spec because the "improvement signal" must reset at each fold's
cold-start — train history from fold N-1 is not informative about
fold N's improvement trajectory.
**Exception:** `REWARD_COMPONENT_GRAD_RATIO_BASE` slots — these accumulate
across the fold and reset to sentinel at fold boundary. The other slots
(`val_sharpe_delta`, `saboteur_engagement_rate`) need at least 2 epochs
within a fold to compute a delta; they're signal-bootstrapped so the first
epoch of each fold uses the floor (no signal yet).
This includes:
- 6 component weights (340..346)
- 4 controller scalar outputs (346..350)
- 2 val-sharpe canaries (350..352)
- 6 grad-ratio canaries (352..358)
- 1 saboteur engagement (358)
- 1 PnL magnitude EMA (359)
**Cold-start window:** The Z-score is undefined until at least 2 val
emits exist within the fold (need delta + variance). For epoch 0 of each
fold, the controller observes `delta_ema = 0` (post-Pearl-A bootstrap),
which gives `dz = 0`, `improving = 0.5`, `stagnant_or_worse = 0.5` — the
"perfectly stagnant" mid-state. Every controller output is well-defined
in this state: weights blend 50/50, curiosity sits at midpoint, saboteur
sits at the geometric mean of [MIN, MAX]. There is no NaN path.
The **novelty hash table** (§3.5.2) also resets at fold boundary; this is
NOT an ISV slot but a separate device buffer cleared by the state-reset
dispatcher.
## 4. ISV slot allocation
@@ -217,7 +357,7 @@ After SP10's last slot at 339:
| `348` | `REWARD_WEIGHT_FLOOR_INDEX` (adaptive) | 1 |
| `349` | `CURIOSITY_BOUND_INDEX` (signal-relative) | 1 |
| `350` | `VAL_SHARPE_DELTA_EMA_INDEX` (canary) | 1 |
| `351` | `VAL_SHARPE_STD_EMA_INDEX` (canary) | 1 |
| `351` | `VAL_SHARPE_VAR_EMA_INDEX` (canary, true variance) | 1 |
| `[352..358)` | `REWARD_COMPONENT_GRAD_RATIO_BASE` (6 canaries) | 6 |
| `358` | `SABOTEUR_ENGAGEMENT_RATE_INDEX` (canary) | 1 |
| `359` | `PNL_REWARD_MAGNITUDE_EMA_INDEX` (canary) | 1 |
@@ -228,9 +368,10 @@ Total: **20 new slots**. Bump `SP5_SLOT_END = 360`, `ISV_TOTAL_DIM = 360`.
1. `reward_subsystem_controller_kernel.cu` (the main controller, ~200 LOC)
2. `val_sharpe_delta_compute_kernel.cu` (canary — reads val sharpe history,
writes Δ + |Δ| EMAs, ~80 LOC)
3. `saboteur_engagement_compute_kernel.cu` (canary — reads per-episode Q
shift after saboteur params change, writes engagement rate, ~100 LOC)
writes delta_ema + delta_var_ema (true variance), ~80 LOC)
3. `saboteur_engagement_compute_kernel.cu` (canary — per-bar
|reward_with_saboteur reward_without_saboteur| > EPS_ENGAGEMENT
reduced via block tree-reduce, EMA'd; see §3.3.1 ~100 LOC)
4. `reward_component_grad_ratio_compute_kernel.cu` (canary — reads
per-component grad EMA from existing slots, normalizes to ratios, ~60 LOC)
@@ -266,10 +407,9 @@ Plus modifications to existing kernels:
curiosity always (overfitting prevention).
- **Component starvation guard**: Per-component weight reweighting could in
principle starve a contributor. Mitigated by adaptive floor
`≥ 0.5 × min_grad_ratio` ensuring even the least-contributing component
keeps a non-trivial budget.
Mitigated by adaptive floor `≥ 0.5 × min_grad_ratio` ensuring even the
least-contributing component keeps a non-trivial budget.
`≥ 0.5 × min_grad_ratio` and post-floor renormalization to Σ=1, ensuring
every component keeps a non-trivial budget without inflating total
reward magnitude (which would contaminate PopArt).
- **Saboteur intensity adapts both directions**: When val sharpe is
improving, saboteur intensity rises to keep the policy under realistic
adversarial load (you don't want a model that only works when execution
@@ -286,53 +426,131 @@ Plus modifications to existing kernels:
multipliers)? Current design uses a single scalar mult. Defer to SP11.1
if needed after empirical results.
## 8. Implementation phases (atomic per `feedback_no_partial_refactor`)
## 8. Implementation phases — full straight-up implementation
Single commit:
Two layers, matching SP4/SP5/SP7. Layer A is additive infrastructure
(no behavior change) split per producer for independent revertability;
Layer B is the atomic consumer migration that flips the trainer onto
the new controller. Smoke runs only at the end of each layer for
validation — there is no gating preflight that pauses or defers work.
1. Allocate 20 new ISV slots in `sp5_isv_slots.rs` (range 340..360)
2. Bump `SP5_SLOT_END = 360`, `ISV_TOTAL_DIM = 360`, layout fingerprint
3. Add 4 new producer kernels (controller + 3 canaries)
4. Register kernels in `build.rs`
5. Rust launchers in `gpu_dqn_trainer.rs` for all 4 new kernels
6. Wire launches into `training_loop.rs` at appropriate points
7. **Audit + migrate ALL existing reward weight constants:**
- `mse_loss_kernel.cu:318` (cf_weight = 0.3 → ISV)
- `c51_loss_kernel.cu:789` (cf_weight = 0.3 → ISV)
- Other shaping multipliers found during audit
8. Add curiosity bonus term to reward path (NEW dimension)
9. Modify `gpu_experience_collector.rs:3328` for saboteur_intensity multiplier
10. State reset registry: 20 new FoldReset entries + dispatch arms
11. Contract test passes
12. New pearl: `pearl_reward_as_controlled_subsystem.md` authored
13. MEMORY.md index entry added
14. Audit doc: Fix 39 entry with full architecture summary
15. HEALTH_DIAG line for `sp11_reward` showing all 10 outputs + improvement_z
### Layer A — additive infrastructure (3 commits, no behavior change)
Total estimated: ~1300-1700 LOC across ~12 files. ~2.5-3 hours subagent work.
**A0 — slot allocation + state reset registry (~150 LOC)**
- Allocate 20 new ISV slots (340..360) in `sp5_isv_slots.rs`
- Bump `SP5_SLOT_END = 360`, `ISV_TOTAL_DIM = 360`
- 20 FoldReset entries + 20 dispatch arms in state-reset registry
- Novelty hash table reset entry (1M-slot device buffer, fold-cleared)
- Layout fingerprint bumped
- Contract test passes (slot count + reset arms match)
**A1 — three canary producer kernels (~250 LOC)**
- `val_sharpe_delta_compute_kernel.cu` (Δ + variance EMA, two-pass)
- `saboteur_engagement_compute_kernel.cu` (per-bar engagement, Pearl C)
- `reward_component_grad_ratio_compute_kernel.cu` (existing
per-component grad EMAs → normalized ratios)
- All Pearl-A bootstrapped, Pearl-D smoothed
- `build.rs` cubin registration; Rust launchers in `gpu_dqn_trainer.rs`
- Wire launches into `training_loop.rs`
**A2 — controller producer kernel (~250 LOC)**
- `reward_subsystem_controller_kernel.cu` reads 5 canary slots,
writes 10 outputs to scratch, chained `apply_pearls_ad_kernel`
smooths scratch into ISV
- Novelty hash table device buffer (1M slots × f32) + 42×16 SimHash
projection matrix (deterministic seed, init at trainer construct)
- Build.rs entry, launcher, training_loop wire-up
- HEALTH_DIAG `sp11_reward` line emits 10 outputs + `improvement_z`
### Layer B — atomic consumer migration (1 commit, ~750 LOC)
`feedback_no_partial_refactor`: every consumer of the migrated
contract migrates in one commit. The trainer is on the new controller
end of this commit.
- `mse_loss_kernel.cu:318` — `cf_weight = isv[REWARD_CF_WEIGHT_INDEX]`
- `c51_loss_kernel.cu:789` — same migration
- Full audit + migrate every hardcoded reward-shaping constant in
`cuda_pipeline/*.cu` (expected ~3-5 additional sites; implementer
enumerates and migrates all of them)
- `gpu_experience_collector.rs:3328` — `let ps = saboteur_perturbation_scale
* isv[SABOTEUR_INTENSITY_MULT_INDEX]`
- Replay sampling kernel: curiosity bonus added **at replay time**
(§3.5.1) reading `isv[CURIOSITY_PRESSURE_INDEX]` × `novelty_signal`
- Reward composition reads all 6 component weights from ISV
- Stale doc-string sweep across migrated sites
### Layer C — validation + close-out
**C1** — L40S 5-epoch smoke (`multi_fold_convergence`) on Layer B HEAD
**C2** — full T10 (3-seed × 3-fold × 50-epoch) on Layer B HEAD
**C3** — audit doc: Fix 39 entry with architecture summary + canary
evolution evidence + Layer B before/after metrics
**C4**`pearl_reward_as_controlled_subsystem.md` authored
**C5** — MEMORY.md index entry
**Total estimated:** Layer A ≈ 650 LOC + Layer B ≈ 750 LOC + Layer C
≈ 150 LOC = ~1550 LOC across ~12 files. Layer A: ~1.5 hours. Layer B:
~1 hour. Layer C: ~30 min. Plus validation wall-clock (smoke ≈ 25 min,
T10 ≈ 4 hr).
## 9. Validation criteria
After SP11 lands:
### 9.1 Smoke (5-epoch L40S `multi_fold_convergence`)
**Smoke (5-epoch L40S):**
- HEALTH_DIAG `sp11_reward` line emits with all weights in [floor, 1.0]
- `improvement_z` evolves across epochs (not stuck at 0)
- `curiosity_pressure` oscillates with stagnation
- No regression in Fix 33-38 metrics (IQN warnings still 0, dir_entropy
still > 0.5, val_active_frac still > 0.20)
Hard-pass criteria (all must hold):
- HEALTH_DIAG `sp11_reward` line emits every epoch with all weights in
[adaptive_floor, 1.0] and Σweights ≈ 1.0 ± 1e-3 (renormalization works)
- `improvement_z` is non-zero by ep2 (canary populates after Pearl A
bootstrap on ep1's first delta)
- `curiosity_pressure ≥ 0.2 × curiosity_bound` always (permanent floor)
and `≤ curiosity_bound` always
- `saboteur_intensity ∈ [0.5, 2.0]` always (post-clamp)
- No regression in Fix 33-38 metrics: IQN parallel-branch warnings = 0,
dir_entropy > 0.5, val_active_frac > 0.20
**T10 (50-epoch full validation):**
- val sharpe trajectory shows continued IMPROVEMENT (not just stagnation
or decline) at least into ep20
- Q-value continues to grow with val sharpe (not decoupled like ep0-14
baseline)
- Component weight evolution visible in HEALTH_DIAG (different components
win at different training stages)
### 9.2 T10 (3-seed × 3-fold × 50-epoch L40S)
**Specific success metric:** `val_sharpe[ep20] > val_sharpe[ep1]`. The Fix
33-38 baseline showed `ep1 > ep14` (peak at ep1, declining). SP11
success = peak shifts to ep20+ or beyond.
Aggregation: 9 trajectories. Per-trajectory peak-epoch is the epoch
maximizing val sharpe within that trajectory.
Hard-pass criteria (all must hold):
- **Median peak-epoch across 9 trajectories ≥ 10**
(Fix 33-38 baseline: median peak-epoch = 1, where xkjkb seed-0 fold-0
peaked at ep1.) This is the primary signal that the model continues
learning past ep1.
- **Median val sharpe at ep20 > 0.95 × median val sharpe at peak-epoch**
(the controller may shift the peak forward; a 5% drop from peak by ep20
is acceptable noise. A larger drop indicates regression.)
- **Mean val sharpe across all 9 trajectories at ep20 ≥ Fix 33-38 baseline
ep1 mean** (sanity check — SP11 cannot make ep20 *worse* than the
baseline's best epoch.)
Soft criteria (informative, not gating):
- Component weight evolution visible across training (different
components dominate at different stages — std(weight[c]) > floor over
full T10)
- Q-value growth correlated with val sharpe (Pearson(train Q, val sharpe)
> 0.3 within each trajectory; baseline showed Pearson = -0.7 at the
ep0-14 window — the decoupled-overfitting signature)
- Per-fold pattern: fold 0 may peak earliest, fold 2 latest (cumulative
novelty hash effects), but no fold collapses (peak-epoch ≥ 5 in every
fold of every seed)
### 9.3 If T10 fails
Fix-forward, not roll-back. Failure modes and responses:
- Median peak-epoch < 10 but z-canary populated: controller has signal
but isn't acting strongly enough — extend ISV outputs to additional
shaping multipliers found during Layer B audit
- z-canary stuck at 0: variance EMA is mis-tracking; review Pearl A
bootstrap on `VAL_SHARPE_VAR_EMA`
- Component weights collapse to floor: renormalization or floor
computation bug; review §3.4 implementation
- Curiosity dominates loss (`r_total / r_base > 1.0` median):
CURIOSITY_BOUND_FRACTION too high; this is the only Invariant-1
fraction we'd revisit and still not because we hardcoded a regime,
but because the chosen rate-limiter was too permissive
## 10. New pearl: `pearl_reward_as_controlled_subsystem`