plastic / tests /test_coordinate_learner.py
dmontgomery40's picture
Sync from GitHub via hub-sync (part 2)
c10c824 verified
Raw History Blame Contribute Delete
5.58 kB
"""The coordinate candidate under the contract: fresh state per measurement, freeze as the
no-adaptation control, revert exact, and both modes producing a serializable report."""
from __future__ import annotations
import json
import pytest
import torch
from plastic.data.mechanisms import mechanism_batch, split_combinations
from plastic.eval.contract import ContractSpec, run_contract
from plastic.eval.coordinate_learner import CoordinateLearner
from plastic.model.coordinate import CoordinateConfig, CoordinateDynamics
def _tiny(**kw) -> CoordinateDynamics:
torch.manual_seed(0)
chunk = kw.pop("chunk", 16)
cfg = CoordinateConfig(d_model=32, n_heads=2, n_layers=2, chunk=chunk, **kw)
return CoordinateDynamics(cfg)
def _spec() -> ContractSpec:
# one 32-step episode per scored row; chunk 16 puts one update boundary inside it
return ContractSpec(seq_len=32, eval_batch=2, stream_episodes=4, probe_steps=20)
def test_contract_refuses_a_chunk_as_long_as_the_episode():
learner = CoordinateLearner(_tiny(chunk=32), mode="frozen")
with pytest.raises(ValueError, match="no fast-update boundary"):
run_contract(learner, _spec(), seed=1)
def test_rows_are_measured_independently():
learner = CoordinateLearner(_tiny(), mode="frozen")
b = _batch(4)
both = learner.step_mse(b, adapt=True)
from dataclasses import replace
alone = [
learner.step_mse(replace(b, inputs=b.inputs[i : i + 1], target_delta=b.target_delta[i : i + 1], worlds=b.worlds[i : i + 1], reset_flag=b.reset_flag[i : i + 1]), adapt=True)
for i in range(2)
]
assert torch.allclose(both[0], alone[0][0], atol=1e-6) and torch.allclose(both[1], alone[1][0], atol=1e-6)
def _batch(seed: int = 0):
train, heldout = split_combinations(k=2, n_heldout=5, seed=0)
g = torch.Generator().manual_seed(seed)
return mechanism_batch(2, seq_len=32, episodes_per_seq=1, combos=heldout, policy="gaussian", rng=g)
def test_step_mse_starts_fresh_every_call_and_freeze_differs_from_adapt():
learner = CoordinateLearner(_tiny(), mode="frozen")
b = _batch()
a1 = learner.step_mse(b, adapt=True)
a2 = learner.step_mse(b, adapt=True)
f1 = learner.step_mse(b, adapt=False)
assert a1.shape == (2, 32) and torch.equal(a1, a2), "a measurement must not depend on earlier calls"
assert not torch.equal(a1, f1), "fast updates must change predictions after the first chunk"
# within the first chunk nothing has been proposed yet, so adapt and freeze agree there
assert torch.allclose(a1[:, :16], f1[:, :16])
def test_fast_signals_summary_reports_proposals_only_when_stepped():
learner = CoordinateLearner(_tiny(), mode="frozen")
b = _batch(3)
assert learner.fast_signals_summary() is None
learner.step_mse(b, adapt=True)
s = learner.fast_signals_summary()
# a 32-step episode with chunk 16 crosses two boundaries, at steps 16 and 32
assert s["boundaries"] == 2 and s["stepped_fraction"] == 1.0
assert s["inner_loss_before"] is not None and s["inner_loss_after"] is not None
assert len(s["dW_norm_by_layer"]) == 2 == len(s["dtheta_norm_by_layer"]) == len(s["eta_by_layer"])
assert s["r_peak_inf"] <= s["bound_peak"] + 1e-6
learner.step_mse(b, adapt=False)
f = learner.fast_signals_summary()
assert f["stepped_fraction"] == 0.0 and f["inner_loss_before"] is None and f["dW_norm_by_layer"] is None
def test_fast_updates_off_makes_adapt_equal_freeze():
learner = CoordinateLearner(_tiny(fast_updates=False), mode="frozen")
b = _batch(1)
assert torch.allclose(learner.step_mse(b, adapt=True), learner.step_mse(b, adapt=False))
@pytest.mark.parametrize("mode", ["frozen", "continued"])
def test_contract_runs_and_reverts_exactly(mode):
model = _tiny()
learner = CoordinateLearner(model, mode=mode, lr=1e-3, steps=2)
report = run_contract(learner, _spec(), seed=3)
json.dumps(report)
assert report["revert"]["ok"] is True, report["revert"]
assert report["compute"]["parameters"] == model.num_params()
if mode == "frozen":
assert report["acceptance"]["n_good"] == 0
assert all(row["delta_mse"] == 0.0 for row in report["transfer"].values())
else:
assert report["acceptance"] == {"accepted_good": 1.0, "refused_bad": 0.0, "n_good": 2, "n_bad": 1}
assert report["decisions"][0]["accepted"] is True
def _moved_keys(model: CoordinateDynamics, batch) -> set[str]:
"""Which fast-parameter keys changed after one adapting pass from the initial state."""
from plastic.model.coordinate import THETA_KEY, W_KEYS
x, y = batch.inputs, batch.target_delta
state0 = model.init_state(int(x.shape[0]))
with torch.no_grad():
_, state1, _ = model(x, state0, mode="chunk", target_delta=y, commit=True)
moved: set[str] = set()
for layer0, layer1 in zip(state0.omega, state1.omega):
for k in tuple(W_KEYS) + (THETA_KEY,):
if not torch.equal(layer0[k], layer1[k]):
moved.add("theta" if k == THETA_KEY else "W")
return moved
def test_decay_only_and_coordinates_only_switches_move_only_their_parameters():
# at initialization the fast step barely changes predictions (about 1e-5), so distinctness
# is checked on which fast parameters move, not on the outputs
b = _batch(2)
assert _moved_keys(_tiny(), b) == {"W", "theta"}
assert _moved_keys(_tiny(adapt_W=False), b) == {"theta"}
assert _moved_keys(_tiny(adapt_theta=False), b) == {"W"}
assert _moved_keys(_tiny(fast_updates=False), b) == set()