| """Config layering: two-layer merge, key validation, delta-only presets, |
| ablation poolability, W&B naming and publish-time checkpoint selection.""" |
|
|
| import ast |
| import importlib.util |
| import json |
| import math |
| import os |
| import shutil |
| import subprocess |
| import sys |
| from pathlib import Path |
|
|
| import pytest |
| import yaml |
|
|
| from src.config import load_config |
|
|
| _ROOT = Path(__file__).resolve().parents[1] |
| _CONFIGS = _ROOT / "configs" |
| _ABL_CONFIGS = _ROOT / "experiments" / "rl_finetuning" / "configs" |
| _ABL_DEFAULT = _ABL_CONFIGS / "ablations_default.yaml" |
|
|
|
|
| def _load_run_ablations(): |
| spec = importlib.util.spec_from_file_location( |
| "run_ablations_under_test", |
| _ROOT / "experiments" / "rl_finetuning" / "run_ablations.py", |
| ) |
| module = importlib.util.module_from_spec(spec) |
| sys.modules[spec.name] = module |
| spec.loader.exec_module(module) |
| return module |
|
|
|
|
| @pytest.fixture(scope="module") |
| def ra(): |
| return _load_run_ablations() |
|
|
|
|
| def _write(directory: Path, name: str, body: str) -> Path: |
| path = directory / name |
| path.write_text(body) |
| return path |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_none_path_returns_empty(ra): |
| assert ra._load_ablation_config(None) == {} |
|
|
|
|
| def test_base_loads_alone(ra): |
| base = yaml.safe_load(_ABL_DEFAULT.read_text()) |
| assert ra._load_ablation_config(str(_ABL_DEFAULT)) == base |
|
|
|
|
| def test_empty_config_inherits_full_base(ra, tmp_path): |
| base = yaml.safe_load(_ABL_DEFAULT.read_text()) |
| cfg = _write(tmp_path, "empty.yaml", "") |
| assert ra._load_ablation_config(str(cfg)) == base |
|
|
|
|
| def test_machine_config_overrides_base(ra, tmp_path): |
| base = yaml.safe_load(_ABL_DEFAULT.read_text()) |
| cfg = _write(tmp_path, "machine.yaml", "batch_size: 7\n") |
| merged = ra._load_ablation_config(str(cfg)) |
| assert merged["batch_size"] == 7 |
| assert set(merged) == set(base) |
|
|
|
|
| def test_preset_does_not_inherit_from_another_preset(): |
| """Presets are a single layer over defaults.yaml; no config chains.""" |
| for path in _CONFIGS.glob("*.yaml"): |
| raw = yaml.safe_load(path.read_text()) or {} |
| assert "extends" not in raw, f"{path.name} declares extends" |
| for path in _ABL_CONFIGS.glob("*.yaml"): |
| raw = yaml.safe_load(path.read_text()) or {} |
| assert "extends" not in raw, f"{path.name} declares extends" |
|
|
|
|
| def test_extends_rejected_as_config_key(): |
| """The mechanism is gone, so the key must now be an error, not ignored.""" |
| cfg = _write(_CONFIGS, "_tmp_extends.yaml", "extends: final_minihack_gpu_h200.yaml\n") |
| try: |
| with pytest.raises(KeyError, match="extends"): |
| load_config("configs/_tmp_extends.yaml") |
| finally: |
| cfg.unlink() |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_unknown_key_in_ablation_config_raises(ra, tmp_path): |
| cfg = _write(tmp_path, "typo.yaml", "batch_sze: 512\n") |
| allowed = set(yaml.safe_load(_ABL_DEFAULT.read_text())) |
| with pytest.raises(KeyError, match="batch_sze"): |
| ra._load_ablation_config(str(cfg), allowed=allowed) |
|
|
|
|
| def test_known_key_in_ablation_config_passes(ra, tmp_path): |
| cfg = _write(tmp_path, "fine.yaml", "batch_size: 512\n") |
| allowed = set(yaml.safe_load(_ABL_DEFAULT.read_text())) |
| assert ra._load_ablation_config(str(cfg), allowed=allowed)["batch_size"] == 512 |
|
|
|
|
| def test_shipped_ablation_configs_validate(ra): |
| allowed = set(yaml.safe_load((_CONFIGS / "defaults.yaml").read_text())) | set( |
| yaml.safe_load(_ABL_DEFAULT.read_text()) |
| ) |
| for name in ("ablations_final_minihack_gpu_h200.yaml", "ablations_final_minihack_gpu_24gb.yaml"): |
| ra._load_ablation_config(str(_ABL_CONFIGS / name), allowed=allowed) |
|
|
|
|
| def test_extends_rejected_as_cli_override(): |
| with pytest.raises(KeyError, match="extends"): |
| load_config("configs/final_minihack_gpu_24gb.yaml", {"extends": "x.yaml"}) |
|
|
|
|
| |
| |
| |
|
|
|
|
| @pytest.mark.parametrize( |
| "preset", |
| sorted(p.name for p in _CONFIGS.glob("*.yaml") if p.name != "defaults.yaml"), |
| ) |
| def test_preset_restates_no_inherited_value(preset): |
| defaults = yaml.safe_load((_CONFIGS / "defaults.yaml").read_text()) |
| raw = yaml.safe_load((_CONFIGS / preset).read_text()) or {} |
| restated = {k: v for k, v in raw.items() if k in defaults and defaults[k] == v} |
| assert not restated, f"{preset} restates inherited values: {restated}" |
|
|
|
|
| @pytest.mark.parametrize( |
| "preset", |
| sorted( |
| p.name |
| for p in _ABL_CONFIGS.glob("*.yaml") |
| if p.name not in {"ablations_default.yaml", "ablations_fast.yaml"} |
| ), |
| ) |
| def test_ablation_config_restates_no_inherited_value(preset): |
| base = yaml.safe_load(_ABL_DEFAULT.read_text()) |
| raw = yaml.safe_load((_ABL_CONFIGS / preset).read_text()) or {} |
| restated = {k: v for k, v in raw.items() if k in base and base[k] == v} |
| assert not restated, f"{preset} restates inherited values: {restated}" |
|
|
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| _MACHINE_KEYS = frozenset( |
| {"num_collection_workers", "collect_num_workers", "collect_output"} |
| ) |
|
|
|
|
| def test_paper_configs_differ_only_in_machine_keys(): |
| gpu_h200 = vars(load_config("configs/final_minihack_gpu_h200.yaml")) |
| gpu_24gb = vars(load_config("configs/final_minihack_gpu_24gb.yaml")) |
| diverged = { |
| k |
| for k in set(gpu_h200) | set(gpu_24gb) |
| if k != "device" and gpu_h200.get(k, "<absent>") != gpu_24gb.get(k, "<absent>") |
| } |
| assert diverged <= set(_MACHINE_KEYS), ( |
| "final_minihack_gpu_h200.yaml and final_minihack_gpu_24gb.yaml must train an identical " |
| f"model. They diverge on non-machine key(s): " |
| f"{sorted(diverged - set(_MACHINE_KEYS))}. Move the shared value into " |
| "configs/defaults.yaml." |
| ) |
|
|
|
|
| @pytest.mark.parametrize("preset", ["final_minihack_gpu_h200.yaml", "final_minihack_gpu_24gb.yaml"]) |
| def test_paper_config_holds_only_machine_keys(preset): |
| raw = yaml.safe_load((_CONFIGS / preset).read_text()) or {} |
| stray = set(raw) - set(_MACHINE_KEYS) |
| assert not stray, ( |
| f"{preset} should hold only machine values; found {sorted(stray)}. " |
| "Anything shared with the other cluster belongs in configs/defaults.yaml." |
| ) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| _OFFLINE_PINS = ( |
| "offline_total_grad_steps", |
| "offline_eval_every_grad_steps", |
| "offline_checkpoint_every_grad_steps", |
| "offline_buffer_capacity", |
| ) |
|
|
| |
| _DERIVES_OFFLINE_BUDGET = [ |
| "smoke.yaml", |
| "ablation_local_only.yaml", |
| "gpu_24gb_bigger_model.yaml", |
| "gpu_24gb_learning_behaviour.yaml", |
| ] |
|
|
|
|
| @pytest.mark.parametrize("preset", _DERIVES_OFFLINE_BUDGET) |
| def test_preset_pins_offline_overrides_to_null(preset): |
| raw = yaml.safe_load((_CONFIGS / preset).read_text()) or {} |
| missing = [k for k in _OFFLINE_PINS if raw.get(k, "<absent>") is not None] |
| assert not missing, ( |
| f"{preset} must pin {missing} to null. Omitting them inherits the " |
| "paper recipe's pins, which override the env-step-derived budget: " |
| "smoke would train 60000 offline grad steps instead of 19." |
| ) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| _REFERENCE_CONFIG = "ablations_final_minihack_gpu_24gb.yaml" |
|
|
| |
| |
| |
|
|
| |
| _POOLABLE = {_REFERENCE_CONFIG} |
|
|
| |
| |
| _NOT_POOLABLE = { |
| "ablations_final_minihack_gpu_h200.yaml": frozenset( |
| { |
| "batch_size", |
| "episodes_per_iter", |
| "diffusion_steps_collect", |
| "eval_episodes", |
| } |
| ), |
| } |
|
|
|
|
| def _machine_configs() -> list[str]: |
| return sorted(p.name for p in _ABL_CONFIGS.glob("ablations_final_*.yaml")) |
|
|
|
|
| def _divergence(ra, name: str) -> set[str]: |
| reference = ra._load_ablation_config(str(_ABL_CONFIGS / _REFERENCE_CONFIG)) |
| candidate = ra._load_ablation_config(str(_ABL_CONFIGS / name)) |
| return { |
| k |
| for k in ra._RESULT_AFFECTING |
| if candidate.get(k, "<absent>") != reference.get(k, "<absent>") |
| } |
|
|
|
|
| def test_every_machine_config_is_classified(): |
| """A new machine config must be declared poolable or not, never silently.""" |
| unclassified = set(_machine_configs()) - _POOLABLE - set(_NOT_POOLABLE) |
| assert not unclassified, ( |
| f"Unclassified ablation machine config(s): {sorted(unclassified)}. " |
| "Add to _POOLABLE (and align result-affecting keys with " |
| f"{_REFERENCE_CONFIG}) or to _NOT_POOLABLE with the diverging keys." |
| ) |
|
|
|
|
| @pytest.mark.parametrize("name", sorted(_POOLABLE)) |
| def test_poolable_config_matches_reference(ra, name): |
| diverged = _divergence(ra, name) |
| assert not diverged, ( |
| f"{name} is declared poolable with {_REFERENCE_CONFIG} but diverges " |
| f"on result-affecting key(s): {sorted(diverged)}. Align them or move " |
| "it to _NOT_POOLABLE." |
| ) |
|
|
|
|
| @pytest.mark.parametrize("name", sorted(_NOT_POOLABLE)) |
| def test_not_poolable_divergence_is_recorded(ra, name): |
| """Catches drift in both directions: new divergence, and silent alignment.""" |
| expected = _NOT_POOLABLE[name] |
| actual = _divergence(ra, name) |
| assert actual == set(expected), ( |
| f"{name} divergence from {_REFERENCE_CONFIG} has changed. " |
| f"Recorded: {sorted(expected)}; actual: {sorted(actual)}. " |
| "Update _NOT_POOLABLE, or move it to _POOLABLE if now aligned." |
| ) |
|
|
|
|
| |
| |
| |
| |
| |
|
|
|
|
| def _results_file(tmp_path: Path, name: str, config: dict, scores: list[float]): |
| """Write a minimal results.json recording *config*. |
| |
| Args: |
| tmp_path: Directory to write into. |
| name: File name. |
| config: The config to record, exactly as given. |
| scores: Per-seed scores for the single ablation in the file. |
| |
| Returns: |
| The path, as a string. |
| """ |
| payload = { |
| "pretrained_score": 0.5, |
| "config": config, |
| "ablations": { |
| "baseline_rl": { |
| "score": sum(scores) / len(scores), |
| "score_std": 0.0, |
| "all_scores": scores, |
| "history": {}, |
| } |
| }, |
| } |
| path = tmp_path / name |
| path.write_text(json.dumps(payload)) |
| return str(path) |
|
|
|
|
| @pytest.mark.parametrize("name", sorted(_NOT_POOLABLE)) |
| def test_merge_refuses_a_not_poolable_pair(ra, tmp_path, name): |
| """The refusal names every recorded diverging key, with both values.""" |
| paths = [ |
| _results_file( |
| tmp_path, |
| "ref.json", |
| ra._load_ablation_config(str(_ABL_CONFIGS / _REFERENCE_CONFIG)), |
| [1.0], |
| ), |
| _results_file( |
| tmp_path, |
| "cand.json", |
| ra._load_ablation_config(str(_ABL_CONFIGS / name)), |
| [2.0], |
| ), |
| ] |
| with pytest.raises(ValueError) as excinfo: |
| ra._merge_result_files(paths) |
|
|
| message = str(excinfo.value) |
| assert "not poolable" in message |
| for key in _NOT_POOLABLE[name]: |
| assert key in message, f"{key} is a recorded divergence but unnamed" |
|
|
|
|
| def test_merge_refuses_a_file_that_records_no_config(ra, tmp_path): |
| """A distinct refusal: absent is not equal, so it is never merged on trust.""" |
| paths = [ |
| _results_file( |
| tmp_path, |
| "ref.json", |
| ra._load_ablation_config(str(_ABL_CONFIGS / _REFERENCE_CONFIG)), |
| [1.0], |
| ), |
| _results_file(tmp_path, "bare.json", {}, [2.0]), |
| ] |
| with pytest.raises(ValueError, match="records no config"): |
| ra._merge_result_files(paths) |
|
|
|
|
| def test_a_merged_config_is_one_input_file_and_the_merge_is_recorded(ra, tmp_path): |
| """The config recorded beside merged results is one input file's, whole, |
| and which one is written next to it (spec-ablations §1.3). |
| |
| Merging the configs key by key produced a config that matched no input |
| file. The poolability guard forbids the result-affecting keys from |
| diverging, so the chimera can only form out of the rest -- worker |
| counts, output paths, the W&B run name -- but those are what tell a |
| reader which machine produced the numbers, and a per-key blend names a |
| run that never happened. |
| |
| Derivation: two poolable files differing only in the W&B project, a key |
| the guard does not police. The blend takes the second file's value while |
| every other key comes from the first; the fix takes the first file's |
| config entire, and records that it did. |
| """ |
| config_a = ra._load_ablation_config(str(_ABL_CONFIGS / _REFERENCE_CONFIG)) |
| config_a["wandb_project"] = "run-on-gpu-24gb" |
| config_b = dict(config_a) |
| config_b["wandb_project"] = "run-on-gpu-h200" |
| paths = [ |
| _results_file(tmp_path, "a.json", config_a, [1.0, 2.0]), |
| _results_file(tmp_path, "b.json", config_b, [3.0]), |
| ] |
| _, _, merged_config = ra._merge_result_files(paths) |
|
|
| |
| assert merged_config == config_a |
| assert merged_config["wandb_project"] == "run-on-gpu-24gb" |
| assert ra._merge_result_files(paths[::-1])[2] == config_b |
|
|
| |
| provenance = {"inputs": paths, "config_from": paths[0]} |
| payload = json.loads( |
| ra._results_to_json({}, 0.5, merged_config, provenance).decode() |
| ) |
| assert payload["merge_provenance"] == provenance |
| |
| assert "merge_provenance" not in json.loads( |
| ra._results_to_json({}, 0.5, merged_config).decode() |
| ) |
|
|
|
|
| def test_merge_still_pools_two_runs_of_the_same_config(ra, tmp_path): |
| """The guard is a refusal on wrong input only: a poolable pair merges to |
| the same values it did before the guard existed.""" |
| config = ra._load_ablation_config(str(_ABL_CONFIGS / _REFERENCE_CONFIG)) |
| paths = [ |
| _results_file(tmp_path, "a.json", config, [1.0, 2.0]), |
| _results_file(tmp_path, "b.json", config, [3.0]), |
| ] |
| merged, pretrained, merged_config = ra._merge_result_files(paths) |
|
|
| assert merged["baseline_rl"]["all_scores"] == [1.0, 2.0, 3.0] |
| assert merged["baseline_rl"]["score"] == pytest.approx(2.0) |
| assert merged["baseline_rl"]["score_std"] == pytest.approx(math.sqrt(2 / 3)) |
| assert pretrained == pytest.approx(0.5) |
| assert merged_config == config |
|
|
|
|
| |
| |
| |
| |
|
|
|
|
| def test_wandb_names_come_from_the_config_not_a_literal(): |
| """Training and the ablation suite both take project and entity from |
| the config, and the shipped names are the canonical ones.""" |
| cfg = load_config("configs/defaults.yaml") |
| assert cfg.wandb_project == "remdm-planner-minihack" |
|
|
| abl = yaml.safe_load((_ABL_CONFIGS / "ablations_default.yaml").read_text()) |
| assert abl["wandb_project"] == "remdm-planner-minihack-ablations" |
| assert abl["wandb_entity"] == cfg.wandb_entity |
|
|
| |
| for src in ( |
| _ROOT / "src" / "planners" / "logging.py", |
| _ROOT / "experiments" / "rl_finetuning" / "run_ablations.py", |
| ): |
| assert "remdm-planner-minihack" not in src.read_text(), src |
|
|
|
|
| def test_suite_wandb_init_takes_project_and_entity_from_cli_or_config(): |
| """The suite's wandb.init receives both names, CLI overriding config. |
| |
| The entity was a documented ablation-config key that nothing read |
| (step-11 finding U2); this pins the wiring. Source-anchored because |
| the call sits inside main() behind checkpoint loading. |
| """ |
| import inspect |
|
|
| ra = _load_run_ablations() |
| src = inspect.getsource(ra.main) |
|
|
| assert "project=(args.wandb_project or cfg.wandb_project)" in src |
| assert 'entity=(args.wandb_entity or getattr(cfg, "wandb_entity", None))' in src |
|
|
|
|
| |
| |
| |
| |
|
|
|
|
| def _hf_upload(): |
| spec = importlib.util.spec_from_file_location( |
| "hf_upload", _ROOT / "scripts" / "hf_upload.py" |
| ) |
| mod = importlib.util.module_from_spec(spec) |
| spec.loader.exec_module(mod) |
| return mod |
|
|
|
|
| def _plant(directory: Path) -> None: |
| """Write the file minihack discovery looks for.""" |
| (directory / "iter1.pth").write_text("") |
|
|
|
|
| def test_publish_discovery_is_at_the_released_layout_and_skips_downloads(tmp_path): |
| """Checkpoint discovery finds the released layout and never the Hub |
| download copies under `checkpoints/hf/` (both repos' README, Publishing). |
| |
| The two repos failed this in opposite directions. craftax used a |
| fixed-depth glob, which happened to exclude `checkpoints/hf/` because a |
| download sits one level deeper than a real checkpoint -- true by |
| arithmetic, not by intent, and silent if the layout ever changed depth. |
| minihack used a recursive `rglob("*.pth")`, which on its live tree found |
| ten directories, **none of them at the released layout**: seven raw |
| `dagger_<timestamp>/` run directories, the repository root with two loose |
| files, and two artefacts already published, which a publish would have |
| pushed back up into a nested `checkpoints/hf/checkpoints/...` tree. |
| |
| Both now discover at the released layout and both skip `checkpoints/hf/` |
| explicitly, so the exclusion holds at any depth. |
| """ |
| hf = _hf_upload() |
| monkey_ckpts = tmp_path / "checkpoints" |
| released = monkey_ckpts / "online" / "Some-Model-100M" |
| download = monkey_ckpts / "hf" / "checkpoints" / "online" / "Some-Model-100M" |
| stray = monkey_ckpts / "dagger_20260101_000000_abcd" |
| for d in (released, download, stray): |
| d.mkdir(parents=True) |
| _plant(d) |
| |
| shallow_download = monkey_ckpts / "hf" / "Some-Model-100M" |
| shallow_download.mkdir(parents=True) |
| _plant(shallow_download) |
|
|
| hf.CKPTS = monkey_ckpts |
| found = hf.discover_checkpoints() |
|
|
| assert set(found) == {released}, sorted(str(p) for p in found) |
| assert not any(hf._is_download_copy(p) for p in found) |
| assert hf._is_download_copy(download) |
| assert hf._is_download_copy(shallow_download) |
|
|
|
|
|
|
| def test_the_published_config_drops_the_same_environment_keys_in_both_repos(): |
| """The published config keeps the recipe and drops provenance, by one |
| declaration that is byte-identical across the repos (both READMEs). |
| |
| Neither repo dropped `use_wandb`: it starts with neither `wandb_` nor |
| `hub_`, so minihack's prefix rule missed it, and craftax removed only the |
| nested `_wandb` blob, leaving `USE_WANDB`, `WANDB_ENTITY` and |
| `WANDB_PROJECT` in the released `config.yaml`. No credential was exposed |
| either way -- those live in `_wandb` and `wandb-metadata.json`, both |
| already removed -- but the published surface advertised an account and a |
| project that are nothing to do with the recipe, and the two repos |
| advertised different ones. |
| |
| Keys are compared lower-cased because craftax records them UPPERCASE and |
| minihack lower-case. |
| """ |
| hf = _hf_upload() |
|
|
| for key in ("_wandb", "use_wandb", "USE_WANDB", "wandb_project", |
| "WANDB_ENTITY", "hub_repo_id", "HUB_TOKEN"): |
| assert hf.is_environment_key(key), key |
|
|
| for key in ("lr", "LR", "batch_size", "NUM_ENVS", "noise_schedule", |
| "use_amp", "USE_AMP", "hubris", "wandbish"): |
| assert not hf.is_environment_key(key), key |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| def test_environment_keys_are_dropped_below_the_top_level(): |
| """The regression test for the sibling's leak: a nested config snapshot.""" |
| hf = _hf_upload() |
| doc = { |
| "lr": 3e-4, |
| "resume_metadata": { |
| "config_snapshot": { |
| "use_wandb": True, |
| "wandb_project": "remdm-planner-minihack", |
| "wandb_entity": "null", |
| "batch_size": 4608, |
| }, |
| }, |
| } |
| out = hf.scrub(doc) |
|
|
| snap = out["resume_metadata"]["config_snapshot"] |
| assert snap == {"batch_size": 4608}, snap |
| assert out["lr"] == 3e-4 |
| |
| assert hf.environment_key_paths(out) == [] |
|
|
|
|
| def test_environment_keys_are_dropped_inside_lists(): |
| """Nesting through a list is the other way past a dict-only filter.""" |
| hf = _hf_upload() |
| out = hf.scrub({"runs": [{"wandb_run_id": "runid002", "seed": 0}]}) |
| assert out == {"runs": [{"seed": 0}]} |
|
|
|
|
| def test_environment_key_paths_names_where_the_leak_is(): |
| """A scrub that reports 'done' without naming what it removed is how the |
| sibling's leak stayed invisible.""" |
| hf = _hf_upload() |
| paths = hf.environment_key_paths( |
| {"a": {"b": {"wandb_project": "p"}}, "use_wandb": True} |
| ) |
| assert sorted(paths) == ["a.b.wandb_project", "use_wandb"] |
|
|
|
|
| def test_the_scrub_still_shortens_paths_at_depth(): |
| """Both passes recurse; fixing the filter must not lose the shortener.""" |
| hf = _hf_upload() |
| out = hf.scrub({"outer": {"dataset_path": "/very/long/cluster/path/data.npz"}}) |
| assert out["outer"]["dataset_path"] == "path/data.npz" |
|
|
|
|
| def test_dropping_environment_keys_preserves_mapping_type(): |
| """A state dict must stay an OrderedDict; the published checkpoint keeps |
| its structure, and only the environment keys go.""" |
| from collections import OrderedDict |
|
|
| hf = _hf_upload() |
| out = hf.drop_environment_keys( |
| OrderedDict([("wandb_run_id", "x"), ("model_state_dict", |
| OrderedDict([("w", 1), ("b", 2)]))]) |
| ) |
| assert isinstance(out, OrderedDict) |
| assert isinstance(out["model_state_dict"], OrderedDict) |
| assert list(out["model_state_dict"]) == ["w", "b"] |
| assert "wandb_run_id" not in out |
|
|
|
|
| def test_a_clean_document_is_returned_unchanged(): |
| """No environment key anywhere means nothing is rewritten -- the property |
| scrub_checkpoint relies on to copy bytes rather than re-save them.""" |
| hf = _hf_upload() |
| doc = {"lr": 1e-4, "nested": {"batch_size": 8, "envs": ["a", "b"]}} |
| assert hf.scrub(doc) == doc |
| assert hf.environment_key_paths(doc) == [] |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| def test_a_substring_rule_catches_wandb_keys_that_are_not_prefixed(): |
| """`baselines_wandb_project` was live in both released config.yaml files; |
| the sibling's `RESUME_WANDB_RUN_ID` is the same shape.""" |
| hf = _hf_upload() |
| for key in ("baselines_wandb_project", "RESUME_WANDB_RUN_ID", |
| "eval_wandb_entity"): |
| assert hf.is_environment_key(key), key |
| |
| for key in ("wandbish", "hubris", "bandwidth"): |
| assert not hf.is_environment_key(key), key |
|
|
|
|
| def test_the_staged_tree_is_scrubbed_including_copied_directories(tmp_path): |
| """The regression test for the reach bug, in the real staged shape.""" |
| hf = _hf_upload() |
| run = tmp_path / "experiments" / "rl_finetuning" / "outputs" / "abl" |
| (run / "gdelta").mkdir(parents=True) |
| (run / "results.json").write_text(json.dumps({ |
| "config": {"wandb_entity": "null", "batch_size": 4608, |
| "baselines_wandb_project": "x"}, |
| "config_from": "/cluster/scratch/gpu_24gb/project/someone/tmp/cfg.yaml", |
| })) |
| (run / "gdelta" / "gdelta_aggregate.json").write_text(json.dumps( |
| {"sources": ["/cluster/scratch/gpu_24gb/project/someone/out/s0.json"]} |
| )) |
| (run / "diagnosis.md").write_text( |
| "Run at /cluster/scratch/gpu_24gb/project/someone/out/results.json.\n" |
| ) |
|
|
| changed = hf.scrub_staged_tree(tmp_path) |
|
|
| res = json.loads((run / "results.json").read_text()) |
| assert res["config"] == {"batch_size": 4608} |
| assert res["config_from"] == "tmp/cfg.yaml" |
| agg = json.loads((run / "gdelta" / "gdelta_aggregate.json").read_text()) |
| assert agg["sources"] == ["out/s0.json"] |
| assert "/cluster/scratch" not in (run / "diagnosis.md").read_text() |
| assert len(changed) == 3, changed |
|
|
|
|
| def test_shortening_leaves_relative_paths_alone(tmp_path): |
| """The path regex must anchor to an absolute path. |
| |
| Unanchored it matched from the middle of a relative one, so the generated |
| LaTeX comment `experiments/rl_finetuning/analysis/tables.py` became |
| `experimentsanalysis/tables.py` -- a scrub corrupting a file it had no |
| business touching. |
| """ |
| hf = _hf_upload() |
| keep = "Generated by experiments/rl_finetuning/analysis/tables.py." |
| assert hf.shorten_text_paths(keep) == keep |
| assert hf.shorten_text_paths( |
| "at /cluster/scratch/gpu_24gb/project/someone/out/results.json now" |
| ) == "at out/results.json now" |
|
|
| |
| |
| |
| |
| assert hf.shorten_text_paths("/home/user@dom/proj/run/file.txt") == "run/file.txt" |
| assert hf.shorten_text_paths("/cs/a+b/c/d/e.txt") == "d/e.txt" |
|
|
| tex = tmp_path / "tables" |
| tex.mkdir() |
| (tex / "results.tex").write_text(f"% {keep}\n\\newcommand{{\\mhX}}{{1}}\n") |
| before = (tex / "results.tex").read_bytes() |
| assert hf.scrub_staged_tree(tmp_path) == [] |
| assert (tex / "results.tex").read_bytes() == before |
|
|
|
|
| def test_a_clean_staged_tree_keeps_its_bytes(tmp_path): |
| """Nothing to scrub means nothing rewritten, so an untouched release does |
| not churn on the Hub.""" |
| hf = _hf_upload() |
| f = tmp_path / "tables" |
| f.mkdir() |
| (f / "main_results.csv").write_text("Method,Score\nbaseline_rl,0.43\n") |
| before = (f / "main_results.csv").read_bytes() |
| assert hf.scrub_staged_tree(tmp_path) == [] |
| assert (f / "main_results.csv").read_bytes() == before |
|
|
|
|
| def test_inference_payloads_are_scrubbed_not_merely_shortened(tmp_path): |
| """stage_inference shortened paths and filtered no keys.""" |
| hf = _hf_upload() |
| src = tmp_path / "eval.json" |
| src.write_text(json.dumps({"wandb_project": "p", "mean_win_rate": 0.44})) |
| hf.INFERENCE = tmp_path / "results" / "inference" |
| hf.ROOT = tmp_path |
| staging = tmp_path / "staging" |
| hf.stage_inference(staging, [src]) |
| out = json.loads((staging / "results" / "inference" / "eval.json").read_text()) |
| assert out == {"mean_win_rate": 0.44} |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| _STALE_LICENSE = ( |
| 'Copyright (c) 2026 The authors of "The Double Intractability of ' |
| 'Reinforcement Learning for Discrete Diffusion Planners"\n' |
| ) |
| _CURRENT_LICENSE = ( |
| 'Copyright (c) 2026 The authors of "Return-Weighted ELBO Fine-Tuning ' |
| 'Degrades Masked Diffusion Planners"\n' |
| ) |
|
|
| requires_git = pytest.mark.skipif( |
| shutil.which("git") is None, reason="git is not on PATH" |
| ) |
|
|
|
|
| def _checkout(root: Path, license_text: str) -> None: |
| """A throwaway checkout with LICENSE committed. |
| |
| Isolated from the developer's git config: hooks, commit templates and |
| commit.gpgsign would otherwise leak in and make this machine-dependent. |
| """ |
| root.mkdir(parents=True, exist_ok=True) |
| (root / "LICENSE").write_text(license_text) |
| env = { |
| **os.environ, |
| "GIT_CONFIG_GLOBAL": os.devnull, |
| "GIT_CONFIG_SYSTEM": os.devnull, |
| } |
| run = lambda *a: subprocess.run( |
| ["git", *a], cwd=root, env=env, capture_output=True, check=True |
| ) |
| run("init", "-q", "--template=") |
| run("add", "LICENSE") |
| run( |
| "-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", |
| "commit", "-qm", "licence", |
| ) |
|
|
|
|
| def _staged_license(hf, root: Path, staging: Path) -> bytes: |
| """Run the real `stage()` against *root* and return the staged LICENSE. |
| |
| The module constants are bound from ROOT at import (hf_upload.py, top), so |
| rebinding ROOT alone would leave `stage_inference`'s |
| `INFERENCE.relative_to(ROOT)` pointing at the real repository and raising. |
| """ |
| hf.ROOT = root |
| hf.CKPTS = root / "checkpoints" |
| hf.RUNS = root / "experiments" / "rl_finetuning" / "outputs" |
| hf.INFERENCE = root / "results" / "inference" |
| hf.PAPER_FIGURES = root / "results" / "paper_figures" |
| staging.mkdir(parents=True, exist_ok=True) |
| |
| hf.stage(staging, {}, [], [], [], None) |
| return (staging / "LICENSE").read_bytes() |
|
|
|
|
| @requires_git |
| def test_the_published_licence_is_the_one_git_committed(tmp_path): |
| """A clean checkout publishes the committed bytes.""" |
| repo = tmp_path / "repo" |
| _checkout(repo, _CURRENT_LICENSE) |
| staged = _staged_license(_hf_upload(), repo, tmp_path / "staging") |
| assert staged == _CURRENT_LICENSE.encode() |
|
|
|
|
| @requires_git |
| def test_a_clobbered_licence_publishes_gits_bytes_not_the_working_trees( |
| tmp_path, capsys |
| ): |
| """The regression test for the incident: a LICENSE overwritten by a Hub |
| download must not reach the Hub, and the operator must be told.""" |
| repo = tmp_path / "repo" |
| _checkout(repo, _CURRENT_LICENSE) |
| |
| (repo / "LICENSE").write_text(_STALE_LICENSE) |
|
|
| staged = _staged_license(_hf_upload(), repo, tmp_path / "staging") |
|
|
| assert staged == _CURRENT_LICENSE.encode() |
| assert staged != _STALE_LICENSE.encode() |
| assert b"Double Intractability" not in staged |
| assert "differs from the one committed at HEAD" in capsys.readouterr().err |
|
|
|
|
| @requires_git |
| def test_the_published_licence_is_byte_exact(tmp_path): |
| """CRLF and a missing trailing newline survive. |
| |
| Fails the moment `capture_output=True` is 'tidied' into `text=True`, or |
| `git cat-file blob` is swapped back for `git show`. |
| """ |
| repo = tmp_path / "repo" |
| awkward = 'Copyright (c) 2026\r\nNo trailing newline here.' |
| repo.mkdir() |
| (repo / "LICENSE").write_bytes(awkward.encode()) |
| _checkout(repo, awkward) |
|
|
| staged = _staged_license(_hf_upload(), repo, tmp_path / "staging") |
| assert staged == awkward.encode() |
|
|
|
|
| def test_publishing_from_a_non_git_checkout_still_works_and_says_so( |
| tmp_path, capsys |
| ): |
| """A tarball or slim container must still publish -- with a warning.""" |
| repo = tmp_path / "repo" |
| repo.mkdir() |
| (repo / "LICENSE").write_text(_CURRENT_LICENSE) |
|
|
| staged = _staged_license(_hf_upload(), repo, tmp_path / "staging") |
|
|
| assert staged == _CURRENT_LICENSE.encode() |
| assert "UNVERIFIED" in capsys.readouterr().err |
|
|
|
|
| @requires_git |
| def test_an_uncommitted_licence_falls_back_to_the_working_tree(tmp_path, capsys): |
| """`git init` with nothing committed is a distinct branch from 'no repo'.""" |
| repo = tmp_path / "repo" |
| repo.mkdir() |
| (repo / "LICENSE").write_text(_CURRENT_LICENSE) |
| subprocess.run(["git", "init", "-q", "--template="], cwd=repo, check=True) |
|
|
| staged = _staged_license(_hf_upload(), repo, tmp_path / "staging") |
|
|
| assert staged == _CURRENT_LICENSE.encode() |
| assert "UNVERIFIED" in capsys.readouterr().err |
|
|
|
|
| def test_git_missing_falls_back_rather_than_failing_the_publish( |
| tmp_path, capsys, monkeypatch |
| ): |
| """Decision: git is consulted, never required.""" |
| repo = tmp_path / "repo" |
| repo.mkdir() |
| (repo / "LICENSE").write_text(_CURRENT_LICENSE) |
| monkeypatch.setattr(shutil, "which", lambda _name: None) |
|
|
| staged = _staged_license(_hf_upload(), repo, tmp_path / "staging") |
|
|
| assert staged == _CURRENT_LICENSE.encode() |
| assert "git is not on PATH" in capsys.readouterr().err |
|
|
|
|
| def test_the_model_card_no_longer_recommends_a_clobbering_download(): |
| """The card told users to run the command that causes the bug. |
| |
| `snapshot_download(repo_id=..., local_dir=".")` with no narrowing writes |
| the Hub's own README.md, LICENSE and .gitattributes over a working copy's. |
| """ |
| hf = _hf_upload() |
| row = { |
| "path": "checkpoints/online/Minihack-Online-Diffusion-DAgger-100M", |
| "role": "Diffusion planner (online DAgger)", |
| "env": "MiniHack-Room-Random-5x5-v0", |
| "arch": "4L, d_model 256, 4 heads, horizon 64, 5M params", |
| "step": "563", |
| "detail": "5,657,661 env steps", |
| "size": "100 MB", |
| "restores": "`model_state_dict`", |
| "file": "iter563.pth", |
| "config": "config_563.yaml", |
| } |
| |
| card = hf.model_card("owner/repo", [row], [], [], [], 1.0, None) |
|
|
| assert 'snapshot_download(repo_id="owner/repo", local_dir=".")' not in card |
| assert "ignore_patterns" in card |
| for name in ("README.md", "LICENSE", ".gitattributes"): |
| assert name in card, name |
|
|
|
|
| def _shipped_defaults() -> dict: |
| """The shipped `configs/defaults.yaml` as a published snapshot would be. |
| |
| `selection()` is called on a checkpoint's own config snapshot, which is a |
| scrub of a config of exactly this shape. Feeding the shipped file rather |
| than a hand-built dict is the point: the previous version of these tests |
| supplied the keys the function happened to look for, so both were green |
| while every released DAgger `selection.json` recorded nulls. |
| """ |
| return yaml.safe_load((_CONFIGS / "defaults.yaml").read_text()) |
|
|
|
|
| def test_selection_records_the_dagger_candidate_set(): |
| """A DAgger checkpoint publishes as best-of-N over periodic |
| checkpoints, naming the metric, the cadence and the eval protocol. |
| |
| The cadence and budget are env-step denominated because that is the |
| cadence DAgger checkpoints on; the selected point is its own iteration |
| counter. |
| """ |
| hf = _hf_upload() |
| cfg = _shipped_defaults() |
| stats = {"counter": "dagger_iteration", "value": 600} |
|
|
| sel = hf.selection(cfg, stats, "id_winrate") |
|
|
| assert sel["policy"] == "best-of-N over periodic checkpoints" |
| assert sel["selected"] == {"dagger_iteration": 600} |
| assert sel["selection_metric"] == "id_winrate" |
| assert sel["candidates"] == { |
| "unit": "env_steps", |
| "every": cfg["checkpoint_every_timesteps"], |
| "configured_max": cfg["total_timesteps"], |
| } |
| assert sel["candidates"]["every"] is not None |
| assert sel["candidates"]["configured_max"] is not None |
| assert sel["eval_protocol"] == { |
| "episodes_per_env": cfg["checkpoint_eval_episodes"], |
| "weights": "ema", |
| "id_envs": cfg["id_envs"], |
| "ood_envs": cfg["ood_envs"], |
| } |
|
|
|
|
| def test_selection_switches_units_for_an_offline_checkpoint(): |
| """An offline checkpoint is selected over gradient steps, with the |
| offline pins as the candidate set - not the env-step cadence.""" |
| hf = _hf_upload() |
| cfg = _shipped_defaults() |
| stats = {"counter": "gradient_step", "value": 40_000} |
|
|
| sel = hf.selection(cfg, stats, None) |
|
|
| assert sel["selected"] == {"gradient_step": 40_000} |
| assert sel["selection_metric"] is None |
| assert sel["candidates"] == { |
| "unit": "gradient_steps", |
| "every": cfg["offline_checkpoint_every_grad_steps"], |
| "configured_budget": cfg["offline_total_grad_steps"], |
| } |
| assert sel["eval_protocol"]["weights"] == "ema" |
|
|
|
|
| @pytest.mark.parametrize( |
| "missing", |
| [ |
| "checkpoint_every_timesteps", |
| "total_timesteps", |
| "checkpoint_eval_episodes", |
| "id_envs", |
| "ood_envs", |
| ], |
| ) |
| def test_selection_rejects_a_snapshot_missing_a_key_it_records(missing): |
| """An absent key fails the upload instead of publishing a null. |
| |
| This is the guard the released artefacts lacked: the two renamed keys |
| read as `None` and shipped as `"every": null, "configured_max": null`. |
| """ |
| hf = _hf_upload() |
| cfg = _shipped_defaults() |
| del cfg[missing] |
|
|
| with pytest.raises(KeyError, match=missing): |
| hf.selection(cfg, {"counter": "dagger_iteration", "value": 600}, None) |
|
|
|
|
| _SHIPPED_CONFIGS = (_CONFIGS / "defaults.yaml", _ABL_DEFAULT) |
| _SOURCE_DIRS = ("src", "experiments", "scripts") |
|
|
|
|
| def _production_sources() -> list[Path]: |
| """Every production module: the entry point and the source packages.""" |
| found = [_ROOT / "main.py"] |
| for name in _SOURCE_DIRS: |
| found += sorted((_ROOT / name).rglob("*.py")) |
| return found |
|
|
|
|
| def _normalise(key: str) -> str: |
| """Config keys are lower case in both the YAML and the code here.""" |
| return key |
|
|
|
|
| |
| |
| _NOT_FROM_A_CONFIG_FILE = frozenset( |
| { |
| |
| "device", |
| |
| |
| "env_name", |
| } |
| ) |
|
|
|
|
| _HF_ONLINE = ( |
| _ROOT |
| / "checkpoints" |
| / "hf" |
| / "checkpoints" |
| / "online" |
| / "Minihack-Online-Diffusion-DAgger-100M" |
| ) |
| _needs_artefact = pytest.mark.skipif( |
| not (_HF_ONLINE / "selection.json").exists(), |
| reason="released HF checkpoints not downloaded to checkpoints/hf/", |
| ) |
|
|
|
|
| @_needs_artefact |
| def test_the_released_dagger_selection_is_the_documented_historical_exception(): |
| """The released DAgger `selection.json` keeps its null candidate set. |
| |
| It was written by a `selection()` that read `checkpoint_every` and |
| `max_iterations` after both had been renamed out of the config, so |
| `.get()` returned None. Author decision 2026-08-17: the artefact stays |
| as published and is historical/noncanonical -- the checkpoint's own |
| config snapshot carries the real cadence and budget, so nothing is |
| lost, and rewriting a released file is not worth the churn. |
| |
| This fails if the artefact is silently re-published, or if the README's |
| historical note stops explaining it. A re-publish is fine -- it just has |
| to retire the note and this test together, exactly as the craftax N3 |
| rename did. |
| """ |
| released = json.loads((_HF_ONLINE / "selection.json").read_text()) |
|
|
| assert released["candidates"] == { |
| "unit": "dagger_iterations", |
| "every": None, |
| "configured_max": None, |
| } |
|
|
| note = (_ROOT / "README.md").read_text() |
| assert "Historical note" in note |
| assert '"every": null' in note and "940000" in note |
|
|
|
|
| @_needs_artefact |
| def test_a_publish_from_the_current_code_records_the_real_candidate_set(): |
| """The same snapshot, run through the current `selection()`. |
| |
| The published nulls were never a data loss: the released |
| `config.yaml` declares both canonical keys, so the corrected reader |
| recovers the cadence and budget from the artefact itself. This pins |
| that a re-publish would fix the record rather than repeat it. |
| """ |
| hf = _hf_upload() |
| cfg = hf.scrub(yaml.safe_load((_HF_ONLINE / "config.yaml").read_text())) |
|
|
| sel = hf.selection(cfg, {"counter": "dagger_iteration", "value": 563}, None) |
|
|
| assert sel["candidates"] == { |
| "unit": "env_steps", |
| "every": cfg["checkpoint_every_timesteps"], |
| "configured_max": cfg["total_timesteps"], |
| } |
| assert sel["candidates"]["every"] is not None |
| assert sel["candidates"]["configured_max"] is not None |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _CONFIG_NAMES = frozenset({"config", "cfg", "merged", "conf"}) |
| _NOT_CONFIG_ATTRS = frozenset( |
| {"get", "items", "keys", "values", "update", "copy", "pop", "setdefault"} |
| ) |
|
|
|
|
| def _is_config_ref(node: ast.AST) -> bool: |
| """Is *node* a reference to a merged config? |
| |
| Two shapes reach one and both are production: a bare name (``cfg.KEY``) |
| and an attribute chain ending in a config name (``self.cfg.KEY``, |
| ``ctx.cfg.KEY``). Matching only the first made the scan blind to a fifth |
| of the ablation recipe's readers (sweep S0-3, gate F-7). |
| """ |
| if isinstance(node, ast.Name): |
| return node.id in _CONFIG_NAMES |
| if isinstance(node, ast.Attribute): |
| return node.attr in _CONFIG_NAMES |
| return False |
|
|
|
|
| class _ConfigKeyScanner(ast.NodeVisitor): |
| """Collect every config key a module reads, with its call site.""" |
|
|
| def __init__(self) -> None: |
| self.keys: dict[str, set[str]] = {} |
| self.path = "?" |
|
|
| def _record(self, key: str, node: ast.AST) -> None: |
| if not key.startswith("_"): |
| self.keys.setdefault(key, set()).add(f"{self.path}:{node.lineno}") |
|
|
| def visit_Call(self, node: ast.Call) -> None: |
| if ( |
| isinstance(node.func, ast.Attribute) |
| and node.func.attr == "get" |
| and _is_config_ref(node.func.value) |
| and node.args |
| and isinstance(node.args[0], ast.Constant) |
| and isinstance(node.args[0].value, str) |
| ): |
| self._record(node.args[0].value, node) |
| if ( |
| isinstance(node.func, ast.Name) |
| and node.func.id == "getattr" |
| and len(node.args) >= 2 |
| and _is_config_ref(node.args[0]) |
| and isinstance(node.args[1], ast.Constant) |
| and isinstance(node.args[1].value, str) |
| ): |
| self._record(node.args[1].value, node) |
| self.generic_visit(node) |
|
|
| def visit_Subscript(self, node: ast.Subscript) -> None: |
| if ( |
| _is_config_ref(node.value) |
| and isinstance(node.slice, ast.Constant) |
| and isinstance(node.slice.value, str) |
| ): |
| self._record(node.slice.value, node) |
| self.generic_visit(node) |
|
|
| def visit_Attribute(self, node: ast.Attribute) -> None: |
| if ( |
| _is_config_ref(node.value) |
| and node.attr not in _NOT_CONFIG_ATTRS |
| ): |
| self._record(node.attr, node) |
| self.generic_visit(node) |
|
|
|
|
| def _config_keys_read() -> dict[str, set[str]]: |
| scanner = _ConfigKeyScanner() |
| for source in _production_sources(): |
| scanner.path = str(source.relative_to(_ROOT)) |
| scanner.visit(ast.parse(source.read_text())) |
| return scanner.keys |
|
|
|
|
| def _declared_keys() -> set[str]: |
| declared: set[str] = set() |
| for path in _SHIPPED_CONFIGS: |
| declared |= set(yaml.safe_load(path.read_text()) or {}) |
| return declared |
|
|
|
|
| def test_every_config_key_the_code_reads_is_declared_in_a_shipped_config(): |
| """No production source may read a key no shipped config declares. |
| |
| Every unresolved key must be named in `_NOT_FROM_A_CONFIG_FILE` with the |
| reason it is not declared -- derived at load, injected by the CLI, or read |
| from a foreign config. That list is the point of the test: an entry is a |
| visible decision, whereas F-1 was a silent one. |
| """ |
| read = _config_keys_read() |
| declared = {_normalise(key) for key in _declared_keys()} |
|
|
| unresolved = { |
| key: sorted(sites) |
| for key, sites in read.items() |
| if _normalise(key) not in declared and key not in _NOT_FROM_A_CONFIG_FILE |
| } |
|
|
| assert not unresolved, ( |
| "config keys read by production code that no shipped config declares " |
| f"and no exemption covers: {unresolved}" |
| ) |
|
|
|
|
| def test_the_reachability_scan_reaches_the_code_it_claims_to_cover(): |
| """Guard the scanner itself. |
| |
| A scan that silently matched nothing would pass the test above forever. |
| These floors pin that the production sources are found, that a healthy |
| number of keys is recovered, and that the exemption list has not grown |
| into an allowlist for everything. |
| """ |
| read = _config_keys_read() |
|
|
| assert len(_production_sources()) >= 20 |
| assert len(read) >= 80 |
| assert len(_declared_keys()) >= 80 |
| assert len(_NOT_FROM_A_CONFIG_FILE) <= len(read) // 4 |
| stale = sorted(_NOT_FROM_A_CONFIG_FILE - set(read)) |
| assert not stale, f"exemptions for keys nothing reads any more: {stale}" |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| _EXPECTED_DESCRIPTIONS = { |
| "action_diversity": ( |
| "Baseline ELBO with degenerate (all-same-action) plans discarded" |
| ), |
| "advantage_clip": ( |
| "Baseline ELBO with PPO-style advantage clipping to [1-eps, 1+eps]" |
| ), |
| "attention_only": ( |
| "Baseline ELBO updating only attention weights (Q/K/V/O); FFN frozen" |
| ), |
| "baseline_rl": ( |
| "Return-weighted ELBO -- no modifications" |
| ), |
| "bc_all": ( |
| "Uniform ELBO on all rollout windows (no advantage weighting)" |
| ), |
| "bc_wins": ( |
| "Uniform ELBO on win windows only (no advantage weighting)" |
| ), |
| "entropy_bonus": ( |
| "Baseline ELBO minus entropy bonus (encourages action diversity)" |
| ), |
| "ewc": ( |
| "ELBO + Elastic Weight Consolidation (Fisher diagonal regularisation)" |
| ), |
| "ffn_only": ( |
| "Baseline ELBO updating only FFN layers; attention frozen" |
| ), |
| "frozen_backbone": ( |
| "Baseline ELBO training the action head and token embeddings (backbone frozen)" |
| ), |
| "gradient_surgery": ( |
| "PCGrad: RL gradient projected to remove conflict with BC gradient" |
| ), |
| "head_only": ( |
| "Baseline ELBO updating only the final linear projection" |
| ), |
| "kl_penalty": ( |
| "Return-weighted ELBO + soft KL penalty vs pretrained" |
| ), |
| "layer_ablation_top1": ( |
| "Baseline ELBO updating only the top-1 transformer block + head" |
| ), |
| "layer_ablation_top2": ( |
| "Baseline ELBO updating only the top-2 transformer blocks + head" |
| ), |
| "layer_ablation_top3": ( |
| "Baseline ELBO updating only the top-3 transformer blocks + head" |
| ), |
| "llrd": ( |
| "Baseline ELBO with Layer-wise Learning Rate Decay" |
| ), |
| "lora": ( |
| "Baseline ELBO with LoRA adaptation (rank-r attention projections only)" |
| ), |
| "low_t": ( |
| "Return-weighted ELBO restricted to low-t (fine-detail) regime" |
| ), |
| "mixed_replay": ( |
| "Self-replay: the run's own past online windows resampled into each batch" |
| ), |
| "normalized_adv": ( |
| "Baseline ELBO with (A - mean) / (std + eps) advantage normalisation" |
| ), |
| "reward_filtering": ( |
| "Baseline ELBO trained only on top-75th-percentile return windows" |
| ), |
| "reward_model": ( |
| "Baseline ELBO with advantages re-weighted by a learned MLP reward model" |
| ), |
| "running_stats": ( |
| "Baseline ELBO with EMA running mean/std for advantage normalisation" |
| ), |
| "t_curriculum": ( |
| "ELBO with t range annealed from high-t to low-t over training" |
| ), |
| "trust_region_kl": ( |
| "Baseline ELBO + hard KL trust region via quadratic barrier" |
| ), |
| } |
|
|
|
|
| def test_every_registered_ablation_has_its_pinned_description(): |
| """Character for character, and the same table in the sibling repo.""" |
| from experiments.rl_finetuning.ablations.registry import REGISTRY |
|
|
| assert set(REGISTRY) == set(_EXPECTED_DESCRIPTIONS), ( |
| f"registry/table mismatch: only in registry " |
| f"{sorted(set(REGISTRY) - set(_EXPECTED_DESCRIPTIONS))}, only in " |
| f"table {sorted(set(_EXPECTED_DESCRIPTIONS) - set(REGISTRY))}" |
| ) |
| wrong = { |
| name: (spec.description, _EXPECTED_DESCRIPTIONS[name]) |
| for name, spec in REGISTRY.items() |
| if spec.description != _EXPECTED_DESCRIPTIONS[name] |
| } |
| assert not wrong, f"description drift: {wrong}" |
|
|
|
|
| def test_every_ablation_names_a_hypothesis_and_is_listed_in_the_readme(): |
| """The two other strings a run surfaces. The README check is by name |
| only, for the register reason in the comment above.""" |
| from experiments.rl_finetuning.ablations.registry import REGISTRY |
|
|
| readme = (_ROOT / "experiments" / "README.md").read_text() |
| for name, spec in sorted(REGISTRY.items()): |
| assert spec.hypothesis.strip(), f"{name} has no hypothesis" |
| assert f"`{name}`" in readme, f"{name} is in no README table" |
|
|
|
|
| def test_the_reachability_scan_sees_config_reads_through_an_attribute(): |
| """`self.cfg.KEY` and `ctx.cfg.KEY` are production shapes; the scanner |
| matched only a bare `cfg.KEY` and was blind to both (sweep S0-3, gate |
| F-7). All four access forms are covered, and an unrelated attribute |
| chain must still be ignored.""" |
| scanner = _ConfigKeyScanner() |
| scanner.path = "<synthetic>" |
| scanner.visit( |
| ast.parse( |
| "cfg.plain_name\n" |
| "self.cfg.via_self\n" |
| "ctx.cfg.via_ctx\n" |
| "self.cfg.get('via_self_get', 0)\n" |
| "self.cfg['via_self_subscript']\n" |
| "getattr(self.cfg, 'via_self_getattr', 0)\n" |
| "unrelated.attr.not_a_config\n" |
| ) |
| ) |
| assert set(scanner.keys) == { |
| "plain_name", |
| "via_self", |
| "via_ctx", |
| "via_self_get", |
| "via_self_subscript", |
| "via_self_getattr", |
| } |
|
|