| """Per-ablation behavioural spec tests (step 8). |
| |
| One deterministic behavioural test per ablation mechanism of |
| the ablation spec §2, with expected values from the pinned |
| sources (SPG, Jaques 2017, Kirkpatrick 2017, Sun 2019, Hu 2021, |
| Yu 2020, Kim 2025) or derivations written in the docstrings. The |
| group-C trainable-set tests classify by reproduction: apply the |
| registry optimizer to all-ones gradients and read off which |
| parameters take a non-zero update. xfail(strict=True) marks canonical-vs-implemented |
| disagreements from the defect register or the step-8 findings list. |
| |
| The minihack twin file carries the same mechanisms in its framework. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import logging |
| import math |
| from pathlib import Path |
|
|
| import jax |
| import jax.numpy as jnp |
| import numpy as np |
| import optax |
| import pytest |
| import yaml |
| from tests.conftest import SEED |
|
|
| from experiments.rl_finetuning.ablations.losses import ( |
| LossContext, |
| _ewc_penalty, |
| make_loss_advantage_clip, |
| make_loss_baseline, |
| make_loss_bc_all, |
| make_loss_bc_wins, |
| make_loss_entropy_bonus, |
| make_loss_ewc, |
| make_loss_kl_penalty, |
| make_loss_low_t, |
| make_loss_normalized_adv, |
| make_loss_t_curriculum, |
| make_loss_t_curriculum_jit, |
| make_loss_trust_region_kl, |
| ) |
| from experiments.rl_finetuning.ablations.optimizers import ( |
| gradient_surgery, |
| make_lora_params, |
| make_optimizer_lora_only, |
| merge_lora_into_base, |
| ) |
| from experiments.rl_finetuning.ablations.registry import REGISTRY |
| from experiments.rl_finetuning.ablations.training import ( |
| _build_reward_model, |
| _compute_advantages, |
| _init_replay_buffer, |
| _push_to_buffer, |
| _reward_model_train_step, |
| ) |
| from src.diffusion.schedules import SCHEDULE_MAP |
| from src.planners.model import build_model, init_params |
|
|
| V, H, OBS = 4, 4, 8 |
| B = 4 |
| TINY = { |
| "D_MODEL": 16, "N_HEADS": 2, "N_LAYERS": 2, "D_FF": 16, |
| "OBS_ENCODER_LAYERS": 1, "OBS_ENCODER_WIDTH": 16, "PLAN_HORIZON": H, |
| } |
| LINEAR = SCHEDULE_MAP["linear"] |
| COSINE = SCHEDULE_MAP["cosine"] |
|
|
|
|
| def _uniform_ctx(config=None, horizon=H, vocab=V): |
| """LossContext with a uniform-logits stub model (params ignored).""" |
|
|
| def apply_fn(params, obs, z, t, rng): |
| return jnp.zeros((obs.shape[0], horizon, vocab)) |
|
|
| return LossContext( |
| apply_fn=apply_fn, ref_params=None, schedule_fn=LINEAR[0], |
| schedule_deriv_fn=LINEAR[1], num_actions=vocab, |
| config=config or {}, |
| ) |
|
|
|
|
| def _logit_ctx(ref_logits, config=None, horizon=H): |
| """LossContext whose stub model broadcasts `params` as the logits. |
| |
| ref_params holds the reference distribution's logits, so |
| KL(current || pretrained) has the closed form used in the tests. |
| """ |
|
|
| def apply_fn(params, obs, z, t, rng): |
| return jnp.broadcast_to(params, (obs.shape[0], horizon, params.shape[-1])) |
|
|
| return LossContext( |
| apply_fn=apply_fn, ref_params=jnp.asarray(ref_logits), |
| schedule_fn=LINEAR[0], schedule_deriv_fn=LINEAR[1], |
| num_actions=ref_logits.shape[-1], config=config or {}, |
| ) |
|
|
|
|
| def _batch(b=B, horizon=H, obs_dim=OBS, vocab=V, key=1): |
| k = jax.random.PRNGKey(key) |
| acts = jax.random.randint(k, (b, horizon), 0, vocab) |
| obs = jnp.zeros((b, obs_dim)) |
| return acts, obs, jnp.ones(b) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_compute_advantages_standard_branch_closed_form(): |
| """weight = clip(max(R,0)/(mean(max(R,0))+eps), 0.1, 5.0). |
| |
| Source: spec-ablations §2 baseline_rl effective params (SPG eq (5) |
| positive branch with the pinned deviations). Derivation: returns |
| [0,1,2,3] -> clipped mean 1.5 -> raw weights [0, 2/3, 4/3, 2] -> |
| floor lifts the first to 0.1. |
| """ |
| adv, mean, std = _compute_advantages( |
| jnp.array([0.0, 1.0, 2.0, 3.0]), 0.1, 5.0, wins_only=False, |
| win_thresh=0.5, use_running_stats=False, ema_decay=0.99, |
| running_mean=jnp.zeros(()), running_std=jnp.ones(()), |
| ) |
| assert np.allclose(np.asarray(adv), [0.1, 2 / 3, 4 / 3, 2.0], atol=1e-4) |
| assert float(mean) == pytest.approx(1.5, abs=1e-6) |
| assert float(std) == pytest.approx(math.sqrt(1.25), abs=1e-5) |
|
|
|
|
| def test_compute_advantages_running_stats_branch_closed_form(): |
| """running_stats: EMA of batch mean/std, adv = clip((w-mu)/sigma + 1, |
| 0.1, 5.0). |
| |
| Source: spec-ablations §2 running_stats row. Derivation with |
| ema_decay d=0.5, prior mean 0 / std 1, batch [0,1,2,3]: |
| new_mean = 0.5*0 + 0.5*1.5 = 0.75; |
| new_std = 0.5*1 + 0.5*(sqrt(1.25)) = 1.0590; |
| adv_i = clip((w_i - 0.75)/1.0590 + 1, 0.1, 5) = |
| [0.2918, 1.2361, 2.1804, 3.1246]. |
| """ |
| adv, mean, std = _compute_advantages( |
| jnp.array([0.0, 1.0, 2.0, 3.0]), 0.1, 5.0, wins_only=False, |
| win_thresh=0.5, use_running_stats=True, ema_decay=0.5, |
| running_mean=jnp.zeros(()), running_std=jnp.ones(()), |
| ) |
| new_std = 0.5 * 1.0 + 0.5 * math.sqrt(1.25) |
| expected = np.clip((np.array([0, 1, 2, 3.0]) - 0.75) / new_std + 1.0, 0.1, 5.0) |
| assert np.allclose(np.asarray(adv), expected, atol=1e-4) |
| assert float(mean) == pytest.approx(0.75, abs=1e-6) |
| assert float(std) == pytest.approx(new_std, abs=1e-5) |
|
|
|
|
| def test_baseline_loss_is_linear_in_the_advantages(): |
| """The advantage weight is a per-sample multiplier on the ELBO |
| (SPG eq (5) positive branch: A * L_ELBO), so scaling every |
| advantage by 2 exactly doubles the loss under the same RNG.""" |
| ctx = _uniform_ctx() |
| loss_fn = make_loss_baseline(ctx) |
| acts, obs, valid = _batch() |
| rng = jax.random.PRNGKey(SEED) |
| adv = jnp.array([0.5, 1.0, 1.5, 2.0]) |
| l1 = float(loss_fn(None, acts, obs, valid, rng, adv)) |
| l2 = float(loss_fn(None, acts, obs, valid, rng, 2.0 * adv)) |
| assert l2 == pytest.approx(2.0 * l1, rel=1e-6) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_bc_wins_averages_uniformly_over_winning_windows(): |
| """Canonical bc_wins ('Uniform ELBO on win windows', win = return > |
| win_threshold, spec-ablations §2; was defect §8.5): a batch with no |
| winning window carries no training signal (loss exactly 0), and an |
| all-winning batch reduces to the plain uniform ELBO. |
| |
| The win masks are produced by the pipeline's own |
| _compute_advantages(wins_only=True), exactly as in the training loop. |
| """ |
| ctx = _uniform_ctx() |
| loss_fn = make_loss_bc_wins(ctx) |
| acts, obs, valid = _batch() |
| rng = jax.random.PRNGKey(SEED) |
|
|
| def mask(returns): |
| m, _, _ = _compute_advantages( |
| jnp.array(returns), 0.1, 5.0, wins_only=True, win_thresh=0.5, |
| use_running_stats=False, ema_decay=0.99, |
| running_mean=jnp.zeros(()), running_std=jnp.ones(()), |
| ) |
| return m |
|
|
| assert float(loss_fn(None, acts, obs, valid, rng, mask([0.0, 0.1, 0.2, 0.3]))) == 0.0 |
| all_wins = float(loss_fn(None, acts, obs, valid, rng, mask([1.0, 2.0, 3.0, 4.0]))) |
| uniform = float(make_loss_baseline(ctx)(None, acts, obs, valid, rng, jnp.ones(B))) |
| assert all_wins == pytest.approx(uniform, abs=0.0) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_bc_all_ignores_the_advantages_and_averages_over_the_whole_batch(): |
| """bc_all separates "trained on self-generated rollouts" from |
| "weighted those rollouts by return": it must discard whatever weight |
| vector the pipeline hands it and reduce to the plain uniform ELBO. |
| |
| Three weight vectors a return-weighted arm would score differently -- |
| all-ones, a spread of real advantages, and the explicit None the |
| factory forwards -- must all give the identical loss, and that loss |
| must equal the baseline's on uniform weights. Unlike bc_wins it keeps |
| the losing windows, so an all-zero weight vector still carries signal. |
| The minihack twin asserts the same four equalities. |
| """ |
| ctx = _uniform_ctx() |
| loss_fn = make_loss_bc_all(ctx) |
| acts, obs, valid = _batch() |
| rng = jax.random.PRNGKey(SEED) |
|
|
| uniform = float(make_loss_baseline(ctx)(None, acts, obs, valid, rng, jnp.ones(B))) |
| for adv in (jnp.ones(B), jnp.array([10.0, 0.0, 1.0, 1.1]), None, jnp.zeros(B)): |
| got = float(loss_fn(None, acts, obs, valid, rng, adv)) |
| assert got == pytest.approx(uniform, abs=0.0), adv |
| assert uniform != 0.0 |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_advantage_clip_clips_the_weight_to_the_documented_band(): |
| """advantage_clip clips the return-weight itself to [1-eps, 1+eps] |
| (project-specific; PPO's ratio clip rejected as source per |
| the source index). With eps=0.2 the ablation loss equals the baseline |
| loss on manually clipped advantages, under the same RNG.""" |
| ctx = _uniform_ctx({"ADV_CLIP_EPS": 0.2}) |
| acts, obs, valid = _batch() |
| rng = jax.random.PRNGKey(SEED) |
| adv = jnp.array([10.0, 0.0, 1.0, 1.1]) |
| got = float(make_loss_advantage_clip(ctx)(None, acts, obs, valid, rng, adv)) |
| want = float( |
| make_loss_baseline(ctx)(None, acts, obs, valid, rng, jnp.clip(adv, 0.8, 1.2)) |
| ) |
| assert got == pytest.approx(want, abs=0.0) |
|
|
|
|
| def test_normalized_adv_standardises_over_the_batch(): |
| """normalized_adv applies (A - mean)/(std + 1e-8) over the batch |
| (What Matters C67; spec-ablations §2). Equals the baseline loss on |
| manually standardised advantages under the same RNG.""" |
| ctx = _uniform_ctx() |
| acts, obs, valid = _batch() |
| rng = jax.random.PRNGKey(SEED) |
| adv = jnp.array([10.0, 0.0, 1.0, 1.1]) |
| norm = (adv - adv.mean()) / (adv.std() + 1e-8) |
| got = float(make_loss_normalized_adv(ctx)(None, acts, obs, valid, rng, adv)) |
| want = float(make_loss_baseline(ctx)(None, acts, obs, valid, rng, norm)) |
| assert got == pytest.approx(want, abs=0.0) |
|
|
|
|
| |
| |
| |
|
|
| _P_LOGITS = jnp.log(jnp.array([0.7, 0.1, 0.1, 0.1])) |
| _Q_LOGITS = jnp.zeros(4) |
| |
| _KL_PQ = 0.7 * math.log(2.8) + 0.3 * math.log(0.4) |
| _P1_LOGITS = jnp.log(jnp.array([0.55, 0.15, 0.15, 0.15])) |
| _KL_P1Q = 0.55 * math.log(2.2) + 0.45 * math.log(0.6) |
|
|
| _KL_H = 512 |
| |
| |
|
|
|
|
| def test_kl_penalty_adds_coef_times_the_closed_form_kl(): |
| """kl_penalty adds kl_coef * KL(current || pretrained) on masked |
| positions (Jaques 2017 eqs (2)-(4); spec-ablations §2 kl_penalty). |
| |
| Derivation: constant per-position logits make the masked-position |
| KL equal KL(p||q) = 0.7 ln 2.8 + 0.3 ln 0.4 = 0.445846 in every row |
| with at least one mask. Loss difference between kl_coef 0.3 and 0.1 |
| under identical RNG isolates 0.2 * KL. |
| """ |
| acts, _, valid = _batch(horizon=_KL_H) |
| obs = jnp.zeros((B, OBS)) |
| rng = jax.random.PRNGKey(SEED) |
| losses = {} |
| for coef in (0.1, 0.3): |
| ctx = _logit_ctx(_Q_LOGITS, {"KL_COEF": coef}, horizon=_KL_H) |
| losses[coef] = float( |
| make_loss_kl_penalty(ctx)(_P_LOGITS, acts, obs, valid, rng, jnp.ones(B)) |
| ) |
| got_kl = (losses[0.3] - losses[0.1]) / 0.2 |
| assert got_kl == pytest.approx(_KL_PQ, rel=1e-3) |
|
|
|
|
| def test_kl_penalty_is_zero_when_current_equals_pretrained(): |
| """KL(p||p) = 0: with params == ref the penalty vanishes, so the |
| kl_penalty loss equals its own RL term. The penalty shares the |
| ELBO's forward pass, so the RL term is the plain core loss at the |
| same RNG.""" |
| from experiments.rl_finetuning.ablations.losses import _core_loss |
|
|
| ctx = _logit_ctx(_Q_LOGITS, {"KL_COEF": 0.1}, horizon=_KL_H) |
| acts, _, valid = _batch(horizon=_KL_H) |
| obs = jnp.zeros((B, OBS)) |
| rng = jax.random.PRNGKey(SEED) |
| got = float( |
| make_loss_kl_penalty(ctx)(_Q_LOGITS, acts, obs, valid, rng, jnp.ones(B)) |
| ) |
| want = float(_core_loss(ctx, _Q_LOGITS, rng, acts, obs, valid, jnp.ones(B))) |
| assert got == pytest.approx(want, abs=1e-6) |
|
|
|
|
| def test_penalty_terms_share_the_elbo_forward_pass(): |
| """The KL and entropy penalties are evaluated at the ELBO's own |
| noised sample, not at an independently drawn t (PARITY |
| 'Ablation-suite mechanics'; the minihack twin shares the pass). |
| |
| Counting model calls is the direct evidence: kl_penalty runs the |
| current model once (shared with the ELBO) plus the frozen |
| reference once, and entropy_bonus runs it once in total. An |
| independent draw would add a second current-model forward. |
| """ |
| acts, _, valid = _batch(horizon=_KL_H) |
| obs = jnp.zeros((B, OBS)) |
| rng = jax.random.PRNGKey(SEED) |
| seen: list[jax.Array] = [] |
|
|
| def counting_ctx(config): |
| def apply_fn(params, obs_, z, t, key): |
| seen.append(t) |
| return jnp.broadcast_to( |
| params, (obs_.shape[0], _KL_H, params.shape[-1]) |
| ) |
|
|
| return LossContext( |
| apply_fn=apply_fn, |
| ref_params=jnp.asarray(_Q_LOGITS), |
| schedule_fn=LINEAR[0], |
| schedule_deriv_fn=LINEAR[1], |
| num_actions=_Q_LOGITS.shape[-1], |
| config=config, |
| ) |
|
|
| make_loss_kl_penalty(counting_ctx({"KL_COEF": 0.1}))( |
| _P_LOGITS, acts, obs, valid, rng, jnp.ones(B) |
| ) |
| assert len(seen) == 2, "kl_penalty must run current + reference once each" |
| assert jnp.allclose(seen[0], seen[1]), "both forwards must use the ELBO's t" |
|
|
| seen.clear() |
| make_loss_entropy_bonus(counting_ctx({"ENTROPY_COEF": 0.01}))( |
| _P_LOGITS, acts, obs, valid, rng, jnp.ones(B) |
| ) |
| assert len(seen) == 1, "entropy_bonus must reuse the ELBO's logits" |
|
|
|
|
| def test_trust_region_barrier_is_zero_below_and_quadratic_above(): |
| """trust_region_kl adds a quadratic barrier c*max(KL-delta,0)^2 |
| (spec-ablations §2 trust_region row; delta=0.05). |
| |
| Below the threshold (params == ref, KL=0) the barrier is exactly 0. |
| Above it, the barrier for two KL levels K0=0.445846 and K1=0.203781 |
| (closed forms as in the KL test) satisfies the quadratic ratio |
| ((K0-delta)/(K1-delta))^2 = (0.395846/0.153781)^2 = 6.6262 - this |
| pins the barrier's form without pinning the project-specific c. |
| """ |
| from experiments.rl_finetuning.ablations.losses import _core_loss |
|
|
| acts, _, valid = _batch(horizon=_KL_H) |
| obs = jnp.zeros((B, OBS)) |
| rng = jax.random.PRNGKey(SEED) |
|
|
| def barrier(cur_logits): |
| ctx = _logit_ctx(_Q_LOGITS, {"TRUST_REGION_KL": 0.05}, horizon=_KL_H) |
| total = float( |
| make_loss_trust_region_kl(ctx)(cur_logits, acts, obs, valid, rng, jnp.ones(B)) |
| ) |
| rl = float(_core_loss(ctx, cur_logits, rng, acts, obs, valid, jnp.ones(B))) |
| return total - rl |
|
|
| assert barrier(_Q_LOGITS) == pytest.approx(0.0, abs=1e-6) |
| b0, b1 = barrier(_P_LOGITS), barrier(_P1_LOGITS) |
| assert b0 > 0 and b1 > 0 |
| want_ratio = ((_KL_PQ - 0.05) / (_KL_P1Q - 0.05)) ** 2 |
| assert b0 / b1 == pytest.approx(want_ratio, rel=2e-2) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_ewc_penalty_closed_form_and_factory_scaling(): |
| """EWC adds lambda * sum_i F_i (theta_i - theta*_i)^2. |
| |
| Source: Kirkpatrick 2017 eq (3); the repo folds the paper's 1/2 |
| into lambda (documented reparameterisation, spec-ablations §2). |
| Derivation: F={a:[1,2]}, theta={a:[3,5]}, theta*={a:[1,1]} -> |
| penalty = 1*(2^2) + 2*(4^2) = 36; with ewc_lambda=100 the factory |
| loss exceeds the same-RNG core loss by exactly 3600. |
| """ |
| fisher = {"a": jnp.array([1.0, 2.0])} |
| theta = {"a": jnp.array([3.0, 5.0])} |
| ref = {"a": jnp.array([1.0, 1.0])} |
| assert float(_ewc_penalty(fisher, theta, ref)) == pytest.approx(36.0) |
|
|
| from experiments.rl_finetuning.ablations.losses import _core_loss |
|
|
| def apply_fn(params, obs, z, t, rng): |
| return jnp.zeros((obs.shape[0], H, V)) |
|
|
| ctx = LossContext( |
| apply_fn=apply_fn, ref_params=ref, schedule_fn=LINEAR[0], |
| schedule_deriv_fn=LINEAR[1], num_actions=V, |
| config={"EWC_LAMBDA": 100.0}, |
| ) |
| acts, obs, valid = _batch() |
| rng = jax.random.PRNGKey(SEED) |
| got = float(make_loss_ewc(ctx, fisher)(theta, acts, obs, valid, rng, jnp.ones(B))) |
| rl = float(_core_loss(ctx, theta, rng, acts, obs, valid, jnp.ones(B))) |
| assert got - rl == pytest.approx(3600.0, rel=1e-5) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_entropy_bonus_subtracts_coef_times_the_closed_form_entropy(): |
| """entropy_bonus subtracts entropy_coef * H(p_theta) on masked |
| positions (spec-ablations §2). Derivation: constant per-position |
| p=[0.7,0.1,0.1,0.1] gives H = -(0.7 ln 0.7 + 0.3 ln 0.1) = 0.940448 |
| (the masked average is globally normalised, so it is exact whenever |
| any position is masked). The coefficient difference 0.03-0.01 |
| isolates -0.02 * H. |
| """ |
| acts, obs, valid = _batch() |
| rng = jax.random.PRNGKey(SEED) |
| losses = {} |
| for coef in (0.01, 0.03): |
| ctx = _logit_ctx(_Q_LOGITS, {"ENTROPY_COEF": coef}) |
| losses[coef] = float( |
| make_loss_entropy_bonus(ctx)(_P_LOGITS, acts, obs, valid, rng, jnp.ones(B)) |
| ) |
| entropy = -(0.7 * math.log(0.7) + 0.3 * math.log(0.1)) |
| assert (losses[0.01] - losses[0.03]) / 0.02 == pytest.approx(entropy, rel=1e-4) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def _recording_ctx(config, records): |
| def apply_fn(params, obs, z, t, rng): |
| records.append(np.asarray(t)) |
| return jnp.zeros((obs.shape[0], H, V)) |
|
|
| return LossContext( |
| apply_fn=apply_fn, ref_params=None, schedule_fn=LINEAR[0], |
| schedule_deriv_fn=LINEAR[1], num_actions=V, config=config, |
| ) |
|
|
|
|
| def test_low_t_restricts_sampling_to_the_low_noise_regime(): |
| """low_t trains only on t in [eps, t_max_low=0.2] |
| (spec-ablations §2 low_t row).""" |
| records: list[np.ndarray] = [] |
| ctx = _recording_ctx({"T_MAX_LOW": 0.2}, records) |
| acts, obs, valid = _batch(b=64) |
| make_loss_low_t(ctx)(None, acts, obs, jnp.ones(64), jax.random.PRNGKey(0), jnp.ones(64)) |
| t = np.concatenate(records) |
| assert t.min() >= 1e-5 - 1e-9 and t.max() <= 0.2 + 1e-6 |
|
|
|
|
| @pytest.mark.parametrize("variant", ["list", "jit"]) |
| def test_t_curriculum_anneals_high_noise_to_low_noise(variant): |
| """t_curriculum anneals the t window from [0.8, 1.0] to |
| [eps, 0.2] linearly over 200 iterations, high-noise (easy) first |
| (Kim 2025, simplified linear anneal per the source index; params |
| t_start=0.8, t_end=0.2, steps=200, spec-ablations §1.6). |
| |
| Expected windows: iter 0 -> [0.8, 1.0]; iter 100 (frac 0.5) -> |
| [0.4, 0.6]; iter >= 200 -> [eps, 0.2]. |
| """ |
| config = {"T_CURRICULUM_START": 0.8, "T_CURRICULUM_END": 0.2, |
| "T_CURRICULUM_STEPS": 200} |
| acts, obs, _ = _batch(b=64) |
| for it, (lo, hi) in [(0, (0.8, 1.0)), (100, (0.4, 0.6)), (200, (1e-5, 0.2))]: |
| records: list[np.ndarray] = [] |
| ctx = _recording_ctx(config, records) |
| if variant == "list": |
| fn = make_loss_t_curriculum(ctx, [it]) |
| fn(None, acts, obs, jnp.ones(64), jax.random.PRNGKey(0), jnp.ones(64)) |
| else: |
| fn = make_loss_t_curriculum_jit(ctx) |
| fn(None, acts, obs, jnp.ones(64), jax.random.PRNGKey(0), jnp.ones(64), |
| jnp.array(it)) |
| t = np.concatenate(records) |
| assert t.min() >= lo - 1e-6, (it, t.min(), lo) |
| assert t.max() <= hi + 1e-6, (it, t.max(), hi) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_reward_filter_keeps_strictly_above_the_percentile(): |
| """reward_filtering keeps windows with return STRICTLY above the |
| batch percentile (spec-ablations §2, step-9 amendment: same |
| boundary in both repos). |
| |
| Derivation: returns 1..8, 75th percentile (linear interpolation) = |
| 6.25 -> keep {7, 8}. All-equal returns: percentile == value, so |
| strict > keeps nothing. |
| """ |
| from experiments.rl_finetuning.ablations.training import reward_filter_mask |
|
|
| keep = np.asarray(reward_filter_mask(jnp.arange(1.0, 9.0), 75)) |
| assert keep.tolist() == [False] * 6 + [True, True] |
| assert not np.asarray(reward_filter_mask(jnp.full(5, 2.0), 75)).any() |
|
|
|
|
| def test_reward_filter_percentile_ignores_invalid_windows(): |
| """The threshold comes from the valid windows only; an invalid |
| window has no return and must not move it (cross-implementation note 'Ablation-suite |
| mechanics', filter row handling - the minihack twin removes the |
| rows outright, which is the same rule). |
| |
| Derivation: valid returns 1..8 (75th percentile 6.25 -> keep |
| {7, 8}); four invalid rows carrying 100.0 would raise the |
| percentile to 82.75 and keep nothing. |
| """ |
| from experiments.rl_finetuning.ablations.training import reward_filter_mask |
|
|
| returns = jnp.concatenate([jnp.arange(1.0, 9.0), jnp.full(4, 100.0)]) |
| valid = jnp.array([True] * 8 + [False] * 4) |
|
|
| keep = np.asarray(reward_filter_mask(returns, 75, valid)) |
| assert keep.tolist() == [False] * 6 + [True, True] + [False] * 4 |
| |
| assert not np.asarray(reward_filter_mask(returns, 75))[:8].any() |
|
|
|
|
| def test_an_empty_reward_filter_batch_warns(caplog): |
| """A kept count of zero is logged; a non-empty one is silent. |
| |
| Tied returns leave nothing strictly above the percentile, so the |
| keep-mask is all-False, the ELBO and its gradient are exactly zero, and |
| the iteration is indistinguishable from a normal one in every logged |
| quantity. The boundary stays strict, per spec-ablations §2; only the |
| degenerate case becomes visible. |
| """ |
| from experiments.rl_finetuning.ablations.training import ( |
| reward_filter_mask, |
| warn_if_reward_filter_kept_nothing, |
| ) |
|
|
| valid = jnp.ones(4, dtype=bool) |
|
|
| with caplog.at_level(logging.WARNING): |
| tied = reward_filter_mask(jnp.full(4, 2.0), 75, valid) |
| warn_if_reward_filter_kept_nothing(tied, 75) |
| assert "kept 0 of 4 windows" in caplog.text |
|
|
| caplog.clear() |
| with caplog.at_level(logging.WARNING): |
| spread = reward_filter_mask(jnp.arange(1.0, 5.0), 75, valid) |
| warn_if_reward_filter_kept_nothing(spread, 75) |
| assert caplog.text == "" |
|
|
|
|
| def test_the_reward_filtering_branch_is_wired_to_the_warning(): |
| """The filter's only call site emits the warning. |
| |
| Source-anchored: the call sits inside the jitted scan body, which no |
| unit test can reach without running a rollout. Without this the helper |
| could be correct and never invoked -- the shape of the original defect. |
| """ |
| import inspect |
|
|
| from experiments.rl_finetuning.ablations import training |
|
|
| src = inspect.getsource(training) |
| filter_call = "mask = reward_filter_mask(flat_returns, reward_filter_pct, mask)" |
| assert filter_call in src |
| assert ( |
| f"{filter_call}\n " |
| "warn_if_reward_filter_kept_nothing(mask, reward_filter_pct)" |
| ) in src |
|
|
|
|
| def test_action_diversity_discards_degenerate_plans(): |
| """action_diversity keeps only windows with more than one distinct |
| action (spec-ablations §2).""" |
| from experiments.rl_finetuning.ablations.training import action_diversity_mask |
|
|
| acts = jnp.array([[1, 1, 1, 1], [1, 2, 1, 1], [0, 0, 0, 0]]) |
| assert np.asarray(action_diversity_mask(acts)).tolist() == [False, True, False] |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_llrd_learning_rates_decay_geometrically_from_the_head(): |
| """LLRD gives the head base_lr and each layer at depth d from the |
| top base_lr * decay^d; the observation encoder sits below the |
| lowest block (Sun 2019; spec-ablations §2 llrd row, decay 0.9). |
| |
| Method: one AdamW step on all-ones gradients with weight decay 0 |
| and the norm clip disabled; the first-step AdamW update magnitude |
| is lr * ghat/(sqrt(vhat)+eps) = lr/(1+1e-5) uniformly, so update |
| ratios equal LR ratios. With N_LAYERS=2: |
| head(Dense_4)=base; block_1=base*0.9; block_0=base*0.81; |
| everything else 0.9^3. |
| """ |
| model = build_model(TINY, V) |
| params = init_params(model, jax.random.PRNGKey(SEED), OBS, H) |
| config = {**TINY, "LR": 1e-3, "LLRD_DECAY": 0.9, "N_LAYERS": 2, |
| "WEIGHT_DECAY": 0.0, "MAX_GRAD_NORM": 1e9} |
| tx = REGISTRY["llrd"].optimizer_factory(config, params) |
| state = tx.init(params) |
| updates, _ = tx.update(jax.tree.map(jnp.ones_like, params), state, params) |
| flat = jax.tree_util.tree_flatten_with_path(updates)[0] |
|
|
| def group_of(path_str: str) -> str: |
| if "TransformerBlock_0" in path_str: |
| return "block_0" |
| if "TransformerBlock_1" in path_str: |
| return "block_1" |
| if "Dense_4" in path_str: |
| return "head" |
| return "obs_enc" |
|
|
| mags: dict[str, set[float]] = {} |
| for path, leaf in flat: |
| p = "/".join(str(k.key) for k in path) |
| mags.setdefault(group_of(p), set()).add(round(float(jnp.abs(leaf).max()), 10)) |
|
|
| base = max(mags["head"]) |
| expected = {"head": 1.0, "block_1": 0.9, "block_0": 0.81, "obs_enc": 0.9**3} |
| for group, rel in expected.items(): |
| got = max(mags[group]) |
| assert got / base == pytest.approx(rel, rel=1e-4), (group, got / base) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_lora_delta_is_zero_at_init_low_rank_and_scaled_by_alpha_over_r(): |
| """LoRA per Hu 2021 eq (3)/§4: B=0 at init (delta exactly zero), |
| the delta is (alpha/r)*A@B with rank <= r, on attention kernels. |
| |
| The delta expectation is recomputed with NumPy from the A/B factors, |
| independently of merge_lora_into_base. |
| """ |
| rank, alpha = 8, 16.0 |
| model = build_model(TINY, V) |
| params = init_params(model, jax.random.PRNGKey(SEED), OBS, H) |
| lora = make_lora_params(params, rank, jax.random.PRNGKey(7)) |
| assert lora, "no attention kernels matched" |
| for ab in lora.values(): |
| assert np.allclose(np.asarray(ab["B"]), 0.0) |
|
|
| merged0 = merge_lora_into_base(params, lora, alpha, rank) |
| for (pa, la), (_pb, lb) in zip( |
| jax.tree_util.tree_flatten_with_path(params)[0], |
| jax.tree_util.tree_flatten_with_path(merged0)[0], |
| strict=True, |
| ): |
| assert np.array_equal(np.asarray(la), np.asarray(lb)), pa |
|
|
| perturbed = { |
| k: {"A": ab["A"], "B": jnp.ones_like(ab["B"])} for k, ab in lora.items() |
| } |
| merged = merge_lora_into_base(params, perturbed, alpha, rank) |
| flat_base = { |
| "/".join(str(k.key) for k in path): leaf |
| for path, leaf in jax.tree_util.tree_flatten_with_path(params)[0] |
| } |
| flat_merged = { |
| "/".join(str(k.key) for k in path): leaf |
| for path, leaf in jax.tree_util.tree_flatten_with_path(merged)[0] |
| } |
| for path_str, ab in perturbed.items(): |
| a = np.asarray(ab["A"]) |
| bmat = np.asarray(ab["B"]) |
| want = (alpha / rank) * (a @ bmat) |
| got = ( |
| np.asarray(flat_merged[path_str]) - np.asarray(flat_base[path_str]) |
| ).reshape(want.shape) |
| assert np.allclose(got, want, atol=1e-5), path_str |
| assert np.linalg.matrix_rank(got) <= rank |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_pcgrad_projection_closed_form_and_one_sidedness(): |
| """PCGrad: if g_rl . g_bc < 0, g_rl <- g_rl - (g_rl.g_bc/|g_bc|^2) g_bc; |
| otherwise unchanged (Yu 2020 Alg 1; one-sided variant per the source index). |
| |
| Derivation: g_rl=[1,0], g_bc=[-1,1]: dot=-1, |g_bc|^2=2 -> |
| projected = [1,0] - (-1/2)[-1,1] = [0.5, 0.5], orthogonal to g_bc. |
| Non-conflicting g_bc=[1,1] leaves g_rl untouched. |
| """ |
| g_rl = {"w": jnp.array([1.0, 0.0])} |
| out = gradient_surgery(g_rl, {"w": jnp.array([-1.0, 1.0])}) |
| assert np.allclose(np.asarray(out["w"]), [0.5, 0.5], atol=1e-6) |
| assert float(out["w"] @ jnp.array([-1.0, 1.0])) == pytest.approx(0.0, abs=1e-6) |
| out2 = gradient_surgery(g_rl, {"w": jnp.array([1.0, 1.0])}) |
| assert np.allclose(np.asarray(out2["w"]), [1.0, 0.0]) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_mixed_replay_ring_buffer_holds_the_runs_own_windows(): |
| """mixed_replay's buffer holds the run's own rollout windows |
| (self-replay; the 'offline data' one-liner is recorded stale by |
| traceability §5 res. 12) with ring-wrap FIFO semantics and a |
| first-n push cap. |
| |
| Derivation: capacity 4, push 3 rows with returns [0,1,2] (write idx |
| 0..2), then 3 with [3,4,5] (indices 3,0,1) -> buffer returns |
| [4,5,2,3]; count caps at 4. A push with n_new=2 takes only the |
| first 2 rows of its batch. |
| """ |
| buf = _init_replay_buffer(4, H, OBS) |
|
|
| def rows(vals): |
| n = len(vals) |
| return ( |
| jnp.tile(jnp.arange(H, dtype=jnp.int32), (n, 1)), |
| jnp.zeros((n, OBS)), |
| jnp.ones(n, dtype=bool), |
| jnp.array(vals, dtype=jnp.float32), |
| ) |
|
|
| a, o, v, r = rows([0.0, 1.0, 2.0]) |
| buf = _push_to_buffer(buf, a, o, v, r, 3) |
| a, o, v, r = rows([3.0, 4.0, 5.0]) |
| buf = _push_to_buffer(buf, a, o, v, r, 3) |
| assert np.allclose(np.asarray(buf.returns), [4.0, 5.0, 2.0, 3.0]) |
| assert int(buf.count) == 4 |
|
|
| buf2 = _init_replay_buffer(4, H, OBS) |
| a, o, v, r = rows([7.0, 8.0, 9.0]) |
| buf2 = _push_to_buffer(buf2, a, o, v, r, 2) |
| assert int(buf2.count) == 2 |
| assert np.allclose(np.asarray(buf2.returns[:2]), [7.0, 8.0]) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_reward_model_learns_a_linear_return_map(): |
| """The reward model is an MLP regressor on (obs -> return) trained |
| with MSE (spec-ablations §2 reward_model row: width 64, depth 2). |
| 50 gradient steps on a fixed linear target must cut the MSE by more |
| than half (deterministic under the fixed seed). |
| """ |
| _, rm_state = _build_reward_model( |
| OBS, jax.random.PRNGKey(SEED), width=64, depth=2, lr=1e-3 |
| ) |
| k = jax.random.PRNGKey(3) |
| obs = jax.random.normal(k, (64, OBS)) |
| targets = obs[:, 0] * 2.0 + 1.0 |
| _, loss0 = _reward_model_train_step(rm_state, obs, targets) |
| state = rm_state |
| for _ in range(50): |
| state, loss = _reward_model_train_step(state, obs, targets) |
| assert float(loss) < 0.5 * float(loss0) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_running_stats_uses_the_configured_decay(): |
| """The running_stats training closure must consume |
| RUNNING_STATS_EMA_DECAY (spec-ablations §1.6: 0.99) for the |
| advantage EMA and EMA_DECAY for the eval weights (was defect §8.4: |
| one shadowed variable served both). Reuses the step-7 reproduction: |
| build the shipped make_run_ablation closure with distinct sentinel |
| decays and read the captured cells. |
| """ |
| from tests.conftest import load_config |
|
|
| from experiments.rl_finetuning.ablations.training import make_run_ablation |
| from src.planners.model import make_apply_fns |
|
|
| config = { |
| **load_config("configs/defaults.yaml"), |
| **load_config("experiments/rl_finetuning/configs/ablations_default.yaml"), |
| **TINY, "NUM_ACTIONS": V, "MAX_ITER": 1, "NUM_ENVS": 2, |
| "NUM_STEPS": 8, "BATCH_SIZE": 3, "EVAL_EVERY": 1, "EVAL_STEPS": 1, |
| "EVAL_REPLAN": 1, "USE_WANDB": False, "SEED": 0, |
| "RUNNING_STATS_EMA_DECAY": 0.111, "EMA_DECAY": 0.999, |
| } |
| model = build_model(config, V) |
| params = init_params(model, jax.random.PRNGKey(0), OBS, H) |
| apply_eval, apply_train = make_apply_fns(model) |
| run = make_run_ablation( |
| spec=REGISTRY["running_stats"], config=config, pretrained_params=params, |
| apply_train=apply_train, apply_eval=apply_eval, env=None, env_params=None, |
| schedule_fn=COSINE[0], schedule_deriv_fn=COSINE[1], |
| num_actions=V, obs_dim=OBS, |
| ) |
| cells = dict(zip(run.__code__.co_freevars, run.__closure__ or (), strict=False)) |
| assert "running_stats_ema_decay" in cells, "closure extraction failed" |
| assert cells["running_stats_ema_decay"].cell_contents == pytest.approx(0.111) |
| assert cells["eval_ema_decay"].cell_contents == pytest.approx(0.999) |
|
|
|
|
| |
| |
| |
| |
|
|
|
|
| def _trainable_modules(name: str) -> frozenset[str]: |
| model = build_model(TINY, V) |
| params = init_params(model, jax.random.PRNGKey(SEED), OBS, H) |
| tx = REGISTRY[name].optimizer_factory({**TINY, "WEIGHT_DECAY": 0.0}, params) |
| state = tx.init(params) |
| updates, _ = tx.update(jax.tree.map(jnp.ones_like, params), state, params) |
| flat = jax.tree_util.tree_flatten_with_path(updates)[0] |
| modules = set() |
| for path, leaf in flat: |
| p = "/".join(str(k.key) for k in path) |
| if bool(jnp.any(leaf != 0)): |
| modules.add(p.rsplit("/", 1)[0]) |
| return frozenset(modules) |
|
|
|
|
| def _all_modules() -> frozenset[str]: |
| model = build_model(TINY, V) |
| params = init_params(model, jax.random.PRNGKey(SEED), OBS, H) |
| flat = jax.tree_util.tree_flatten_with_path(params)[0] |
| return frozenset( |
| "/".join(str(k.key) for k in path).rsplit("/", 1)[0] for path, _ in flat |
| ) |
|
|
|
|
| |
| |
| |
| _HEAD = frozenset({"params/Dense_4"}) |
|
|
|
|
| def test_frozen_backbone_trains_the_head_and_token_embeddings(): |
| """Canonical set (spec-ablations §2, step-9 amendment): the action |
| head plus the token-interface embeddings (action embedding and the |
| time-embedding MLP); the backbone (obs encoder incl. projection, |
| transformer blocks, all LayerNorms) is frozen. Tiny-arch modules: |
| Dense_2/Dense_3 (t-emb), Dense_4 (head), Embed_0.""" |
| assert _trainable_modules("frozen_backbone") == frozenset( |
| {"params/Dense_2", "params/Dense_3", "params/Dense_4", "params/Embed_0"} |
| ) |
|
|
|
|
| def test_head_only_trains_only_the_final_projection(): |
| """Canonical set (spec-ablations §2, step-9 amendment): exactly the |
| final action projection.""" |
| assert _trainable_modules("head_only") == _HEAD |
|
|
|
|
| def test_attention_only_trains_only_the_attention_projections(): |
| """Canonical set (spec-ablations §2, step-9 amendment): exactly the |
| per-block attention projections Q/K/V/O; norms and head frozen.""" |
| expected = frozenset( |
| m for m in _all_modules() if "MultiHeadDotProductAttention_" in m |
| ) |
| assert _trainable_modules("attention_only") == expected |
|
|
|
|
| def test_ffn_only_trains_only_the_ffn_layers(): |
| """Canonical set (spec-ablations §2, step-9 amendment): exactly the |
| two FFN Dense layers inside each TransformerBlock; norms and head |
| frozen.""" |
| expected = frozenset( |
| m |
| for m in _all_modules() |
| if "TransformerBlock_" in m and ("/Dense_0" in m or "/Dense_1" in m) |
| ) |
| assert _trainable_modules("ffn_only") == expected |
|
|
|
|
| @pytest.mark.parametrize("top_n", [1, 2]) |
| def test_layer_ablation_trains_only_the_top_blocks_and_head(top_n): |
| """Canonical set (spec-ablations §2, step-9 amendment): all |
| parameters of the top-n transformer blocks plus the action head. |
| With N_LAYERS=2 the top-1 set is TransformerBlock_1 + head; top-2 |
| adds TransformerBlock_0. |
| """ |
| kept = {f"params/TransformerBlock_{i}" for i in range(2 - top_n, 2)} |
| expected = frozenset( |
| m for m in _all_modules() if any(m.startswith(k) for k in kept) |
| ) | _HEAD |
| assert _trainable_modules(f"layer_ablation_top{top_n}") == expected |
|
|
|
|
| |
| |
| |
| |
| _FREEZING_ABLATIONS = ( |
| "frozen_backbone", |
| "head_only", |
| "attention_only", |
| "ffn_only", |
| "layer_ablation_top1", |
| "layer_ablation_top2", |
| "layer_ablation_top3", |
| "lora", |
| ) |
| |
| |
| |
| _NON_FREEZING_ABLATIONS = ("llrd", "baseline_rl") |
|
|
| _ABLATIONS_DEFAULT = ( |
| Path(__file__).resolve().parents[1] |
| / "experiments" |
| / "rl_finetuning" |
| / "configs" |
| / "ablations_default.yaml" |
| ) |
|
|
|
|
| @pytest.fixture(scope="module") |
| def prod(): |
| """``(config, params)`` for the model the suite actually trains. |
| |
| Read from the shipped ``ablations_default.yaml`` through the suite's own |
| upper-casing convention, with the action count and observation width |
| taken from the real environment, so the layout tracks production instead |
| of a copy of it. At the pins that is 113 leaves and 9,334,289 parameters: |
| ``N_LAYERS=6``, ``D_MODEL=384``, ``OBS_ENCODER_LAYERS=2``. |
| |
| The TINY arch the tests above use is a different layout, not a smaller |
| one: the group-C selectors derive their Dense indices arithmetically from |
| ``OBS_ENCODER_LAYERS``, so the head is ``Dense_4`` at TINY and ``Dense_5`` |
| here, and ``layer_ablation_top3`` is unreachable with two blocks. |
| """ |
| from src.planners.env import make_env |
|
|
| config = { |
| k.upper(): v |
| for k, v in yaml.safe_load(_ABLATIONS_DEFAULT.read_text()).items() |
| } |
| env, env_params = make_env(config, 1) |
| config["NUM_ACTIONS"] = env.action_space(env_params).n |
| obs_dim = env.observation_space(env_params).shape[0] |
|
|
| model = build_model(config, config["NUM_ACTIONS"]) |
| params = init_params( |
| model, jax.random.PRNGKey(SEED), obs_dim, config["PLAN_HORIZON"] |
| ) |
| return config, params |
|
|
|
|
| def _prod_deltas(config: dict, params, name: str) -> dict[str, float]: |
| """Per-leaf ``max|parameter delta|`` after one step of *name*'s optimiser. |
| |
| Built the way ``run_ablation`` builds it, LoRA branch included, and driven |
| by a non-zero gradient on EVERY leaf, the nominally frozen ones included. |
| The measurement is the applied parameter delta rather than the update, so |
| AdamW's decoupled weight decay is inside it: at the shipped non-zero |
| ``WEIGHT_DECAY`` a frozen leaf that reached the optimiser would move. |
| """ |
| if name == "lora": |
| lora = make_lora_params( |
| params, config.get("LORA_RANK", 8), jax.random.PRNGKey(SEED) |
| ) |
| tree = {"base": params, "lora": lora} |
| tx = make_optimizer_lora_only(config, params, lora) |
| else: |
| tree = params |
| tx = REGISTRY[name].optimizer_factory(config, params) |
|
|
| grads = jax.tree.map(lambda p: jnp.full_like(p, 0.5), tree) |
| updates, _ = tx.update(grads, tx.init(tree), tree) |
| updated = optax.apply_updates(tree, updates) |
|
|
| flat_new = jax.tree_util.tree_flatten_with_path(updated)[0] |
| flat_old = jax.tree_util.tree_flatten_with_path(tree)[0] |
| return { |
| "/".join(str(k.key) for k in path): float(jnp.max(jnp.abs(new - old))) |
| for (path, new), (_, old) in zip(flat_new, flat_old, strict=True) |
| } |
|
|
|
|
| def _moved(deltas: dict[str, float]) -> frozenset[str]: |
| return frozenset(name for name, d in deltas.items() if d != 0.0) |
|
|
|
|
| @pytest.mark.parametrize("name", _FREEZING_ABLATIONS) |
| def test_frozen_leaves_move_exactly_zero_at_the_production_architecture( |
| name, prod |
| ): |
| """Every leaf either moves a full optimiser step or does not move at all. |
| |
| This is the standing guard for the FREEZE defect. ``optax.masked`` leaves |
| NON-selected updates untouched rather than zeroing them, so masking in the |
| trainable parameters handed every "frozen" leaf its raw clipped gradient |
| as an update -- roughly SGD at lr 1.0, in the ascent direction. It survived |
| four months and a green suite because nothing measured a parameter delta, |
| and nothing measured one at the architecture the suite runs. |
| |
| Two properties, both needed: |
| |
| - no leakage: a leaf's delta is exactly 0.0 or at least a tenth of the |
| learning rate. A partial freeze, a stale gradient or decoupled weight |
| decay reaching a frozen leaf all land between those and fail here, |
| where an order-of-magnitude tolerance would pass them. |
| - the partition is real: at least one leaf frozen and at least one leaf |
| trained. An optimiser that trains nothing is the failure the minihack |
| twin raises on; here the whole-tree ``set_to_zero`` label makes it a |
| valid optimiser that silently updates nothing. |
| """ |
| config, params = prod |
| deltas = _prod_deltas(config, params, name) |
| moved = _moved(deltas) |
| floor = config["LR"] / 10 |
|
|
| leaking = {n: d for n, d in deltas.items() if 0.0 < d < floor} |
| assert not leaking, f"{name}: leaves moved by less than a full step: {leaking}" |
| assert moved, f"{name} trained nothing" |
| assert moved < frozenset(deltas), f"{name} froze nothing" |
|
|
|
|
| @pytest.mark.parametrize("name", _NON_FREEZING_ABLATIONS) |
| def test_the_non_freezing_optimisers_freeze_nothing(name, prod): |
| """LLRD and baseline_rl must reach every leaf. |
| |
| LLRD is the other ``multi_transform`` user, so it is swept for the same |
| defect class from the opposite side: its label function has to be total, |
| because any leaf it fails to label would be dropped rather than merely |
| slowed. Its slowest group is ``lr * 0.9 ** (N_LAYERS + 1)``, comfortably |
| above the leakage floor, so "slow" and "frozen" stay distinguishable. |
| """ |
| config, params = prod |
| deltas = _prod_deltas(config, params, name) |
|
|
| assert _moved(deltas) == frozenset(deltas), ( |
| f"{name} froze " |
| f"{sorted(set(deltas) - _moved(deltas))}" |
| ) |
| assert min(deltas.values()) >= config["LR"] / 10 |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _PROD_OBS_ENCODER_LAYERS = 2 |
| _PROD_N_LAYERS = 6 |
| _PROD_HEAD = frozenset({"params/Dense_5"}) |
| |
| |
| _PROD_FROZEN_BACKBONE = _PROD_HEAD | frozenset( |
| {"params/Dense_3", "params/Dense_4", "params/Embed_0"} |
| ) |
|
|
|
|
| def _prod_modules(params) -> frozenset[str]: |
| return frozenset( |
| "/".join(str(k.key) for k in path).rsplit("/", 1)[0] |
| for path, _ in jax.tree_util.tree_flatten_with_path(params)[0] |
| ) |
|
|
|
|
| def _prod_trainable_modules(config: dict, params, name: str) -> frozenset[str]: |
| return frozenset( |
| leaf.rsplit("/", 1)[0] |
| for leaf, delta in _prod_deltas(config, params, name).items() |
| if delta != 0.0 |
| ) |
|
|
|
|
| def test_the_production_layout_is_the_one_the_group_c_expectations_assume(prod): |
| """Pin the architecture the expectations below are derived for. |
| |
| Without this a config change would surface as an unreadable set |
| mismatch in every group-C case at once, rather than as one failure |
| saying the layout moved and the derivations need redoing. |
| """ |
| config, params = prod |
| assert config["OBS_ENCODER_LAYERS"] == _PROD_OBS_ENCODER_LAYERS |
| assert config["N_LAYERS"] == _PROD_N_LAYERS |
| assert len(jax.tree_util.tree_leaves(params)) == 113 |
|
|
|
|
| def test_frozen_backbone_at_production_trains_the_head_and_token_embeddings(prod): |
| """Canonical set (spec-ablations §2, step-9 amendment) at the real |
| Dense layout: Dense_3/Dense_4 (t-emb), Dense_5 (head), Embed_0. The |
| obs encoder including its projection, the six transformer blocks and |
| all LayerNorms are frozen.""" |
| config, params = prod |
| assert _prod_trainable_modules(config, params, "frozen_backbone") == ( |
| _PROD_FROZEN_BACKBONE |
| ) |
|
|
|
|
| def test_head_only_at_production_trains_only_the_final_projection(prod): |
| """Canonical set: exactly the final action projection, which is |
| Dense_5 here and Dense_4 at TINY -- the index defect §8.1/§8.2 turned |
| on.""" |
| config, params = prod |
| trainable = _prod_trainable_modules(config, params, "head_only") |
| assert trainable == _PROD_HEAD |
| assert trainable < _prod_trainable_modules(config, params, "frozen_backbone") |
|
|
|
|
| def test_attention_only_at_production_trains_only_the_attention_projections(prod): |
| """Canonical set: exactly the per-block attention projections across |
| all six blocks; the LayerNorms, the FFNs and the head are frozen.""" |
| config, params = prod |
| expected = frozenset( |
| m for m in _prod_modules(params) if "MultiHeadDotProductAttention_" in m |
| ) |
| assert len(expected) == 4 * _PROD_N_LAYERS |
| assert _prod_trainable_modules(config, params, "attention_only") == expected |
|
|
|
|
| def test_ffn_only_at_production_trains_only_the_ffn_layers(prod): |
| """Canonical set: exactly the two FFN Dense layers inside each of the |
| six blocks; norms and head frozen. The per-block Dense_0/Dense_1 must |
| not be confused with the top-level Dense_0/Dense_1, which are the obs |
| encoder -- the substring collision behind §8.1.""" |
| config, params = prod |
| expected = frozenset( |
| m |
| for m in _prod_modules(params) |
| if "TransformerBlock_" in m and ("/Dense_0" in m or "/Dense_1" in m) |
| ) |
| assert len(expected) == 2 * _PROD_N_LAYERS |
| assert _prod_trainable_modules(config, params, "ffn_only") == expected |
|
|
|
|
| @pytest.mark.parametrize("top_n", [1, 2, 3]) |
| def test_layer_ablation_at_production_trains_only_the_top_blocks_and_head( |
| top_n, prod |
| ): |
| """Canonical set: every parameter of the top-n transformer blocks plus |
| the action head. |
| |
| `layer_ablation_top3` is only reachable here: the TINY arch has two |
| blocks, so the top-3 arm was the one registry entry no test touched. |
| With N_LAYERS=6 the top-1 set is TransformerBlock_5 + head, top-2 adds |
| block 4 and top-3 block 3. |
| """ |
| config, params = prod |
| kept = { |
| f"params/TransformerBlock_{i}" |
| for i in range(_PROD_N_LAYERS - top_n, _PROD_N_LAYERS) |
| } |
| expected = frozenset( |
| m for m in _prod_modules(params) if any(m.startswith(k) for k in kept) |
| ) | _PROD_HEAD |
| assert _prod_trainable_modules( |
| config, params, f"layer_ablation_top{top_n}" |
| ) == expected |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_suite_loss_uses_the_nelbo_weight_and_per_token_normalisation(): |
| """The suite's per-sample loss is the NELBO estimator |
| w(t) * sum_masked(CE) / H (spec-method §3.1/§3.4; spec-ablations §2 |
| baseline row: 'return-weighted ELBO'). |
| |
| Derivation (cosine schedule, uniform logits, t ~ U(eps, 0.2) via the |
| low_t factory, unit advantages): E[per-sample] = E_t[w(t) ln V |
| (1-alpha)/1] = ln V * E_t[-alpha'(t)] = ln V * (1 - cos(0.1 pi))/0.2 |
| = 0.244715 ln V. Statistical: B=16384 rows; per-row second moment |
| is ~0.62 (ln V)^2 (w ~ 2/t and Bin(H=8, 1-alpha) masking), giving |
| sigma ~ 0.0061 ln V; bound 0.03 ln V ~ 4.9 sigma. The minihack twin |
| asserts the same value and xfails: its suite loss drops w(t) and |
| normalises by the realised masked count (step-8 finding). |
| """ |
| b = 16384 |
|
|
| def apply_fn(params, obs, z, t, rng): |
| return jnp.zeros((obs.shape[0], H, V)) |
|
|
| ctx = LossContext( |
| apply_fn=apply_fn, ref_params=None, schedule_fn=COSINE[0], |
| schedule_deriv_fn=COSINE[1], num_actions=V, config={"T_MAX_LOW": 0.2}, |
| ) |
| k = jax.random.PRNGKey(2) |
| acts = jax.random.randint(k, (b, H), 0, V) |
| obs = jnp.zeros((b, 1)) |
| loss = float( |
| make_loss_low_t(ctx)(None, acts, obs, jnp.ones(b), jax.random.PRNGKey(0), |
| jnp.ones(b)) |
| ) |
| expected = math.log(V) * (1 - math.cos(0.1 * math.pi)) / 0.2 |
| assert abs(loss - expected) < 0.03 * math.log(V) |
|
|