Source: tests/dynamic/test_policy_fit.py · Plain text

tests/dynamic/test_policy_fit.py

import copy
import json

import pytest
import torch

from mcss.dynamic.policy import (
    FEATURE_NAMES,
    FEATURE_SCHEMA_VERSION,
    POLICY_FEATURE_DIM,
    LearnedActionPolicy,
)
from mcss.dynamic.policy_checkpoint import load_policy_checkpoint
from mcss.training.policy_fit import (
    ACTION_TEACHER_SCHEMA,
    fit_action_policy,
    load_teacher_dataset,
    main,
    parse_teacher_row,
    split_scene_ids,
)

_BINDING = {
    "model_content_hash": "carrier-abc",
    "feature_schema_version": FEATURE_SCHEMA_VERSION,
    "render_protocol": "render-v1",
    "budget_protocol": "budget-v1",
    "utility_protocol": "utility-v1",
    "teacher_dataset_hash": "dataset-abc",
    "teacher_source_hashes": {"teacher": "hash-abc"},
}


def _row(row_id: str, scene_id: str, value: float, *, binding=None) -> dict:
    features = [0.0] * POLICY_FEATURE_DIM
    features[0] = value
    features[1] = value * 0.5
    return {
        "schema_version": ACTION_TEACHER_SCHEMA,
        "row_id": row_id,
        "episode_id": f"episode-{scene_id}",
        "scene_id": scene_id,
        "split_id": "train",
        "prefix_step": 0,
        "prefix_frame_id": 1,
        "control_input": {},
        "features": features,
        "actions": ["OFF", "FUSE", "COMPLETE", "ALL"],
        "feasible_mask": [True, True, False, True],
        "losses": [1.0 - value, 1.0 + value, None, 1.0 - 0.5 * value],
        "advantages": [value, -value, None, 0.5 * value],
        "costs": [0.0, 2.0, None, 4.0],
        "horizon_used": 2,
        "provenance": dict(_BINDING if binding is None else binding),
    }


def test_scene_split_is_deterministic_and_has_no_row_scene_leak() -> None:
    scenes = ["scene-a", "scene-b", "scene-c", "scene-d", "scene-e"]
    train_one, valid_one = split_scene_ids(scenes, holdout_fraction=0.4, seed=17)
    train_two, valid_two = split_scene_ids(scenes, holdout_fraction=0.4, seed=17)

    assert (train_one, valid_one) == (train_two, valid_two)
    assert set(train_one).isdisjoint(valid_one)
    assert set(train_one) | set(valid_one) == set(scenes)


def test_masked_fit_is_finite_and_learns_continuous_advantages() -> None:
    rows = [_row(f"row-{index}", "scene-a", -1.0 + index * 0.25) for index in range(9)]
    initial = LearnedActionPolicy(hidden_dim=8, seed=19)
    initial_state = {name: value.detach().clone() for name, value in initial.state_dict().items()}

    result = fit_action_policy(
        rows,
        hidden_dim=8,
        epochs=80,
        learning_rate=0.02,
        holdout_fraction=0.0,
        seed=19,
        init_policy=initial,
    )

    assert torch.isfinite(result.policy.feature_mean).all()
    assert torch.isfinite(result.policy.feature_scale).all()
    assert torch.isfinite(torch.tensor(float(result.metrics["training_loss"])))
    assert result.metrics["heldout_row_count"] == 0
    changed = [
        not torch.equal(initial_state[name], value.detach().cpu())
        for name, value in result.policy.state_dict().items()
        if name.startswith("model.")
    ]
    assert any(changed)
    # The masked COMPLETE label is null and cannot create a nonfinite loss.
    assert result.target_scale >= 1e-6


def test_feature_normalization_uses_training_scenes_once() -> None:
    train_rows = [_row("train-a", "scene-train", 2.0), _row("train-b", "scene-train", 4.0)]
    heldout = _row("valid-a", "scene-valid", 100.0)
    result = fit_action_policy(
        [*train_rows, heldout],
        hidden_dim=6,
        epochs=1,
        holdout_fraction=0.0,
        validation_scene_ids=["scene-valid"],
        seed=3,
    )

    assert result.validation_scene_ids == ("scene-valid",)
    assert result.feature_mean[0].item() == pytest.approx(3.0)
    assert result.feature_scale[0].item() == pytest.approx(1.0)
    assert result.policy.target_mean == pytest.approx(result.target_mean)
    assert result.policy.target_scale == pytest.approx(result.target_scale)


def test_categorical_and_near_constant_features_use_identity_guards() -> None:
    action_indices = [
        FEATURE_NAMES.index(f"previous_action_{action.lower()}")
        for action in ("off", "fuse", "complete", "all")
    ]
    varying_fast_index = FEATURE_NAMES.index("fast_total_norm")
    near_constant_fast_index = FEATURE_NAMES.index("fast_max_abs")
    rows = []
    for index in range(4):
        row = _row(f"row-{index}", "scene-a", 0.1 + index * 0.1)
        features = row["features"]
        for action_index in action_indices:
            features[action_index] = 0.0
        features[action_indices[index]] = 1.0
        features[varying_fast_index] = float(index)
        features[near_constant_fast_index] = index * 5e-7
        rows.append(row)

    result = fit_action_policy(rows, hidden_dim=6, epochs=1, holdout_fraction=0)

    torch.testing.assert_close(
        result.feature_mean[action_indices], torch.zeros(len(action_indices))
    )
    torch.testing.assert_close(
        result.feature_scale[action_indices], torch.ones(len(action_indices))
    )
    assert result.feature_scale[near_constant_fast_index].item() == pytest.approx(1.0)
    assert result.feature_scale[varying_fast_index].item() > 1e-6

    changed = torch.stack([torch.tensor(row["features"]) for row in rows])
    changed[:, near_constant_fast_index] = 0.75
    normalized = (changed - result.feature_mean) / result.feature_scale
    assert normalized[:, near_constant_fast_index].abs().max().item() < 1.0
    assert normalized[:, action_indices].abs().max().item() <= 1.0


def test_conflicting_binding_is_rejected() -> None:
    first = _row("row-a", "scene-a", 0.1)
    second_binding = dict(_BINDING)
    second_binding["model_content_hash"] = "carrier-other"
    second = _row("row-b", "scene-b", 0.2, binding=second_binding)

    with pytest.raises(ValueError, match="binding mismatch"):
        fit_action_policy([first, second], holdout_fraction=0.0)


def test_conflicting_duplicate_row_id_is_rejected() -> None:
    first = _row("same-row", "scene-a", 0.1)
    second = copy.deepcopy(first)
    second["advantages"][0] = 0.2

    with pytest.raises(ValueError, match="conflicting duplicate"):
        fit_action_policy([first, second], holdout_fraction=0.0)


def test_future_top_level_fields_are_rejected_by_row_whitelist() -> None:
    row = _row("row-a", "scene-a", 0.1)
    row["future_label"] = 1.0

    with pytest.raises(ValueError, match="fields mismatch"):
        parse_teacher_row(row)


def test_row_json_round_trip_keeps_strict_schema() -> None:
    row = _row("row-a", "scene-a", 0.1)
    parsed = parse_teacher_row(json.loads(json.dumps(row)))
    assert parsed.row_id == "row-a"
    assert parsed.features.shape == (POLICY_FEATURE_DIM,)
    assert parsed.feasible_mask.tolist() == [True, True, False, True]


def test_sidecar_binding_is_carried_into_loaded_rows(tmp_path) -> None:
    row = _row("row-sidecar", "scene-a", 0.1)
    row["provenance"] = {}
    source = tmp_path / "teacher.jsonl"
    source.write_text(json.dumps(row) + "\n", encoding="utf-8")
    source.with_name("teacher.metadata.json").write_text(
        json.dumps(
            {
                "schema_version": "mcss.dynamic.action_teacher.v1",
                "row_schema_version": ACTION_TEACHER_SCHEMA,
                "binding": _BINDING,
            }
        ),
        encoding="utf-8",
    )

    dataset = load_teacher_dataset([source])
    assert dataset.rows[0].binding == _BINDING
    result = fit_action_policy(dataset.rows, epochs=1, holdout_fraction=0)
    assert result.binding["feature_schema_version"] == FEATURE_SCHEMA_VERSION


def test_loaded_rows_drop_expanded_provenance_after_fingerprint(tmp_path) -> None:
    row = _row("row-compact", "scene-a", 0.1)
    row["provenance"] = {
        **_BINDING,
        "source_data": {
            "manifest_sha256": [
                {"path": f"manifest-{index}.json", "sha256": f"hash-{index}"}
                for index in range(8)
            ]
        },
        "episode": {"episode_id": "episode-scene-a", "scene_id": "scene-a"},
    }
    expected = parse_teacher_row(row)
    source = tmp_path / "compact.jsonl"
    source.write_text(json.dumps(row) + "\n", encoding="utf-8")
    source.with_name("compact.metadata.json").write_text(
        json.dumps(
            {
                "schema_version": "mcss.dynamic.action_teacher.v1",
                "row_schema_version": ACTION_TEACHER_SCHEMA,
                "row_provenance_mode": "episode_compact_v1",
                "binding": _BINDING,
            }
        ),
        encoding="utf-8",
    )

    dataset = load_teacher_dataset([source])

    loaded = dataset.rows[0]
    assert loaded.fingerprint == expected.fingerprint
    assert loaded.binding == _BINDING
    assert loaded.provenance == _BINDING


def test_cli_writes_bound_checkpoint_and_refuses_second_output(tmp_path) -> None:
    source = tmp_path / "teacher.jsonl"
    source.write_text(
        "\n".join(
            json.dumps(_row(f"row-{index}", "scene-a", 0.1 + index * 0.01))
            for index in range(3)
        )
        + "\n",
        encoding="utf-8",
    )
    output = tmp_path / "policy.pt"
    assert main(
        [
            "--teacher",
            str(source),
            "--output",
            str(output),
            "--epochs",
            "3",
            "--holdout-fraction",
            "0",
            "--seed",
            "7",
        ]
    ) == 0
    policy, metadata = load_policy_checkpoint(output)
    assert policy.target_scale >= 1e-6
    assert metadata["binding"]["feature_schema_version"] == FEATURE_SCHEMA_VERSION
    with pytest.raises(FileExistsError, match="overwrite"):
        main(
            [
                "--teacher",
                str(source),
                "--output",
                str(output),
                "--epochs",
                "1",
                "--holdout-fraction",
                "0",
            ]
        )