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

tests/dynamic/test_action_teacher.py

import json

import pytest
import torch

import mcss.training.action_teacher as action_teacher
from mcss.dynamic.carrier import DynamicSceneCarrier
from mcss.dynamic.config import CarrierConfig, WriteConfig
from mcss.dynamic.policy import (
    ACTION_ORDER,
    POLICY_FEATURE_DIM,
    FixedPolicy,
    LearnedActionPolicy,
)
from mcss.dynamic.policy_checkpoint import save_policy_checkpoint
from mcss.dynamic.runner import StreamingRunner
from mcss.dynamic.types import Action, OnlineObservation, hash_fast_content, hash_scene_state
from mcss.dynamic.write_rule import DirectWriteRule
from mcss.geometry import make_intrinsics
from mcss.measurements import FixedMeasurementRenderer
from mcss.training.action_teacher import (
    DEFAULT_POLICY_WORK_UNITS,
    ROW_SCHEMA_VERSION,
    _atomic_json,
    _derive_episode_rollin_seed,
    _index_from_rows,
    _load_rollin,
    _read_rows,
    collect_episode_targets,
    make_binding,
)
from mcss.training.supervision import TrainingSupervision
from mcss.types import Cameras


def _config() -> CarrierConfig:
    return CarrierConfig(
        feature_dim=4,
        hidden_dim=4,
        expansion_dim=6,
        grid_size=(4, 4, 4),
        local_bounds_m=((-1.0, -1.0, 0.1), (1.0, 1.0, 3.0)),
        token_count=12,
    )


def _observation(frame_id: int) -> OnlineObservation:
    pose = torch.eye(4)
    pose[0, 3] = 0.05 * frame_id
    camera = Cameras(make_intrinsics((4, 4), 5.0), pose, (4, 4))
    return OnlineObservation(
        scene_id="scene-a",
        frame_id=frame_id,
        rgb=torch.full((3, 4, 4), 0.2 + 0.01 * frame_id),
        camera=camera,
    )


def _runner(*, max_units: float = 1e12) -> StreamingRunner:
    torch.manual_seed(21)
    config = _config()
    return StreamingRunner(
        DynamicSceneCarrier(config),
        DirectWriteRule(config, WriteConfig(learning_rate=0.1, max_update_norm=0.2)),
        FixedMeasurementRenderer(n_samples=4, ray_chunk_size=8),
        FixedPolicy(Action.OFF),
        max_units=max_units,
    )


def _supervision(depth: torch.Tensor | None = None) -> TrainingSupervision:
    query_camera = Cameras(
        make_intrinsics((4, 4), 5.0).view(1, 1, 3, 3),
        torch.eye(4).view(1, 1, 4, 4),
        (4, 4),
    )
    if depth is None:
        depth = torch.full((1, 1, 1, 4, 4), 2.0)
    return TrainingSupervision(
        episode_id="episode-a",
        scene_id="scene-a",
        query_vault_id="vault-a",
        frame_ids=(20,),
        query_cameras=query_camera,
        query_rgb=torch.zeros((1, 1, 3, 4, 4)),
        query_depth=depth,
    )


def _collect(
    runner: StreamingRunner,
    supervision: TrainingSupervision,
    *,
    horizon: int = 2,
    **kwargs,
):
    return collect_episode_targets(
        runner,
        [_observation(0)],
        [_observation(1), _observation(2), _observation(3)],
        supervision,
        episode_id="episode-a",
        scene_id="scene-a",
        query_vault_id="vault-a",
        horizon=horizon,
        **kwargs,
    )


def test_teacher_rows_have_fixed_actions_features_and_nullable_infeasible_values() -> None:
    rows = _collect(_runner(), _supervision())

    assert len(rows) == 3
    assert rows[0]["actions"] == [action.value for action in ACTION_ORDER]
    assert all(len(row["features"]) == POLICY_FEATURE_DIM == 75 for row in rows)
    assert all(all(value is not None for value in row["losses"]) for row in rows)

    limited = _runner(max_units=133869.0)
    limited_rows = _collect(limited, _supervision(), selected_prefix_steps=[0])
    assert limited_rows[0]["feasible_mask"] == [True, False, False, False]
    assert limited_rows[0]["losses"][1:] == [None, None, None]
    assert limited_rows[0]["advantages"][1:] == [None, None, None]
    assert limited_rows[0]["costs"][1:] == [None, None, None]


def test_teacher_direct_collection_charges_hidden32_rollin_and_selected_prefixes() -> None:
    runner = _runner()
    rows = _collect(runner, _supervision(), selected_prefix_steps=[1, 2])

    assert [row["prefix_step"] for row in rows] == [1, 2]
    assert runner.budget.policy_units == DEFAULT_POLICY_WORK_UNITS == 2639
    assert runner.budget.calls["policy"] == 3


def test_learned_rollin_budget_uses_delegate_work_units() -> None:
    runner = _runner()
    policy = LearnedActionPolicy(hidden_dim=8, seed=4)

    _collect(runner, _supervision(), rollin_policy=policy)

    assert runner.budget.policy_units == policy.declared_work_units == 719


def test_horizon_three_uses_off_for_each_continuation(monkeypatch: pytest.MonkeyPatch) -> None:
    suffixes: list[tuple[str, ...]] = []

    def fake_score(branch, utility, renderer_samples):
        if len(branch.history) >= 3:
            suffixes.append(tuple(record["action"] for record in branch.history[-3:]))
        return 0.0

    monkeypatch.setattr(action_teacher, "_query_absrel", fake_score)
    rows = _collect(
        _runner(),
        _supervision(),
        horizon=3,
        selected_prefix_steps=[0],
    )

    assert len(rows) == 1
    assert sorted(suffixes) == sorted(
        [
            (Action.OFF.value, Action.OFF.value, Action.OFF.value),
            (Action.FUSE.value, Action.OFF.value, Action.OFF.value),
            (Action.COMPLETE.value, Action.OFF.value, Action.OFF.value),
            (Action.ALL.value, Action.OFF.value, Action.OFF.value),
        ]
    )


def test_invalid_query_depth_values_are_masked() -> None:
    depth = torch.full((1, 1, 1, 4, 4), 2.0)
    depth[..., 0, 0] = float("nan")
    depth[..., 0, 1] = 0.0
    depth[..., 0, 2] = -1.0

    utility = action_teacher._build_query_utility(_supervision(depth))
    rows = _collect(_runner(), _supervision(depth), selected_prefix_steps=[0])

    assert utility.valid_mask[0, 0, 0, 0, 0:3].tolist() == [False, False, False]
    assert all(
        value is not None and bool(torch.isfinite(torch.tensor(value)))
        for value in rows[0]["losses"]
    )


def test_candidate_branches_do_not_mutate_the_rollin_runner() -> None:
    runner = _runner()
    observations = [_observation(0), _observation(1), _observation(2), _observation(3)]
    _collect(runner, _supervision())

    expected = _runner()
    expected.reset(
        observations[:1],
        episode_id="episode-a",
        scene_id="scene-a",
        split_id="train",
        query_vault_id="vault-a",
        stream_steps=3,
        query_count=1,
    )
    for observation in observations[1:]:
        expected.step(observation)

    assert hash_scene_state(runner.scene_state) == hash_scene_state(expected.scene_state)
    assert hash_fast_content(runner.fast) == hash_fast_content(expected.fast)
    assert [record["action"] for record in runner.history] == [
        Action.OFF.value,
        Action.OFF.value,
        Action.OFF.value,
    ]


def test_query_labels_do_not_change_prefix_features() -> None:
    first = _collect(_runner(), _supervision())
    second = _collect(_runner(), _supervision(torch.full((1, 1, 1, 4, 4), 3.0)))

    assert [row["features"] for row in first] == [row["features"] for row in second]


def test_make_binding_is_the_flat_seven_key_runtime_contract() -> None:
    binding = make_binding(
        model_content_hash="carrier-hash",
        renderer_samples=7,
        ray_chunk_size=13,
        max_units=123.5,
        horizon=2,
    )

    assert set(binding) == {
        "model_content_hash",
        "feature_schema_version",
        "render_protocol",
        "budget_protocol",
        "utility_protocol",
        "teacher_dataset_hash",
        "teacher_source_hashes",
    }
    assert binding["render_protocol"] == {
        "renderer": "FixedMeasurementRenderer",
        "renderer_samples": 7,
        "ray_chunk_size": 13,
    }
    assert binding["budget_protocol"] == {
        "max_units": 123.5,
        "policy_work_units": 2639,
    }
    assert binding["utility_protocol"] == {
        "horizon": 2,
        "horizon_window": "current_postwrite_plus_off_continuation",
        "continuation_action": "OFF",
        "utility": "mean_masked_depth_abs_rel",
        "query_mask": "finite_positive_depth_common_all_views",
        "query_weights": "uniform_valid_pixels",
    }
    assert binding["teacher_dataset_hash"] == ""
    assert binding["teacher_source_hashes"] == {}


def test_all_invalid_query_depth_is_rejected_before_branch_collection() -> None:
    depth = torch.full((1, 1, 1, 4, 4), float("nan"))
    with pytest.raises(ValueError, match="no valid depth"):
        _collect(_runner(), _supervision(depth))


def test_checkpoint_rollin_must_match_canonical_policy_work_units(tmp_path) -> None:
    policy = LearnedActionPolicy(hidden_dim=8, seed=4)
    binding = make_binding(
        model_content_hash="carrier-hash",
        renderer_samples=4,
        ray_chunk_size=8,
        max_units=1e12,
        horizon=2,
    )
    binding["teacher_dataset_hash"] = "dataset-hash"
    binding["teacher_source_hashes"] = {"rows": "rows-hash"}
    path = tmp_path / "noncanonical-rollin.pt"
    save_policy_checkpoint(path, policy, binding=binding)

    with pytest.raises(ValueError, match="canonical binding"):
        _load_rollin(
            path,
            device="cpu",
            expected_binding=binding,
            seed=0,
        )


def test_read_rows_streams_jsonl_and_preserves_blank_line_numbers(
    tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
    path = tmp_path / "action_targets.jsonl"
    row = {
        "schema_version": ROW_SCHEMA_VERSION,
        "row_id": "episode-a:prefix:0",
        "episode_id": "episode-a",
    }
    path.write_text(json.dumps(row) + "\n\n", encoding="utf-8")

    def fail_read_text(*args, **kwargs):
        raise AssertionError("_read_rows must parse JSONL incrementally")

    monkeypatch.setattr(type(path), "read_text", fail_read_text)

    index = _read_rows(path)
    assert index["episode-a"].row_ids == frozenset({row["row_id"]})
    assert index["episode-a"].row_count == 1
    assert index["episode-a"].duplicate_row_count == 0


def test_read_rows_reports_stream_line_number_after_blank_line(tmp_path) -> None:
    path = tmp_path / "action_targets.jsonl"
    path.write_text("\nnot-json\n", encoding="utf-8")

    with pytest.raises(ValueError, match="invalid teacher JSONL line 2"):
        _read_rows(path)


def test_read_rows_retains_only_compact_identity_metadata(tmp_path) -> None:
    path = tmp_path / "action_targets.jsonl"
    rows = [
        {
            "schema_version": ROW_SCHEMA_VERSION,
            "row_id": f"episode-a:prefix:{index}",
            "episode_id": "episode-a",
            "features": ["large-payload" * 1000],
        }
        for index in range(2)
    ]
    path.write_text("".join(json.dumps(row) + "\n" for row in rows), encoding="utf-8")

    index = _read_rows(path)["episode-a"]

    assert index.row_ids == frozenset(row["row_id"] for row in rows)
    assert index.row_count == len(rows)
    assert not hasattr(index, "rows")
    assert "large-payload" not in repr(index)


def test_resume_index_round_trip_and_partial_overlap_are_exact(tmp_path) -> None:
    rows = [
        {
            "schema_version": ROW_SCHEMA_VERSION,
            "row_id": f"collection:episode-a:prefix:{index}",
            "episode_id": "episode-a",
            "features": [index],
        }
        for index in range(2)
    ]
    path = tmp_path / "action_targets.jsonl"
    path.write_text("".join(json.dumps(row) + "\n" for row in rows), encoding="utf-8")

    durable = _read_rows(path)["episode-a"]
    current = _index_from_rows(rows)

    assert durable.row_fingerprints == current.row_fingerprints
    assert durable.row_ids == current.row_ids
    assert durable.row_count == current.row_count == 2
    partial = _index_from_rows(rows[:1])
    assert partial.row_ids != current.row_ids

    duplicate_path = tmp_path / "duplicate.jsonl"
    duplicate_path.write_text(
        json.dumps(rows[0]) + "\n" + json.dumps(rows[0]) + "\n", encoding="utf-8"
    )
    duplicate = _read_rows(duplicate_path)["episode-a"]
    assert duplicate.row_ids == durable.row_ids - {rows[1]["row_id"]}
    assert duplicate.row_count == 2
    assert duplicate.duplicate_row_count == 1


def test_random_rollin_episode_seed_is_stable_and_episode_specific() -> None:
    first = _derive_episode_rollin_seed(17, "episode-a")
    second = _derive_episode_rollin_seed(17, "episode-b")

    assert first == _derive_episode_rollin_seed(17, "episode-a")
    assert first != second
    assert 0 <= first < 2**63


def test_atomic_json_retries_windows_replace_lock(
    tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
    target = tmp_path / "progress.json"
    original_replace = action_teacher.os.replace
    attempts = 0

    def flaky_replace(source, destination):
        nonlocal attempts
        attempts += 1
        if attempts < 3:
            raise PermissionError("transient Windows sharing violation")
        return original_replace(source, destination)

    monkeypatch.setattr(action_teacher.os, "replace", flaky_replace)
    _atomic_json(target, {"row_count": 2})

    assert attempts == 3
    assert json.loads(target.read_text(encoding="utf-8")) == {"row_count": 2}
    assert not list(tmp_path.glob("progress.json.*.part"))