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

tests/dynamic/test_experiment.py

import json

import numpy as np
import pytest
import torch
from PIL import Image

from mcss.dynamic.checkpoint import load_dynamic_checkpoint
from mcss.dynamic.config import CarrierConfig, WriteConfig
from mcss.training.experiment import (
    CpuEpisodeCache,
    DynamicExperimentConfig,
    load_train_episode_index,
    run_dynamic_experiment,
)


def _write_fixture(
    tmp_path,
    *,
    observed_ids: tuple[int, ...] = tuple(range(12)),
    query_ids: tuple[int, ...] = tuple(range(12, 16)),
    all_invalid_query: int | None = None,
):
    prepared = tmp_path / "prepared"
    rgb_root = prepared / "train" / "scene-a" / "rgb"
    depth_root = prepared / "train" / "scene-a" / "depth"
    rgb_root.mkdir(parents=True)
    depth_root.mkdir(parents=True)
    image_size = [4, 6]
    for frame_id in (*observed_ids, *query_ids):
        Image.fromarray(np.full((4, 6, 3), 30 + frame_id, dtype=np.uint8)).save(
            rgb_root / f"{frame_id:06d}.png"
        )
    for frame_id in query_ids:
        depth = np.full((4, 6), 2.0, dtype=np.float32)
        if frame_id == query_ids[0]:
            depth[0, 0] = np.nan
        if frame_id == all_invalid_query:
            depth.fill(np.nan)
        np.save(depth_root / f"{frame_id:06d}.npy", depth)

    def record(frame_id, *, query=False):
        result = {
            "frame_id": frame_id,
            "intrinsics": [[4.0, 0.0, 2.5], [0.0, 4.0, 1.5], [0.0, 0.0, 1.0]],
            "c2w": np.eye(4).tolist(),
            "rgb": str(rgb_root / f"{frame_id:06d}.png"),
        }
        if query:
            result["depth"] = str(depth_root / f"{frame_id:06d}.npy")
        return result

    identity = {
        "episode_id": "train--scene-a--cam-00",
        "scene_id": "scene-a",
        "split_id": "train",
        "query_vault_id": "query-a",
        "image_size": image_size,
    }
    online_path = tmp_path / "online.json"
    online_path.write_text(
        json.dumps(
            {
                "schema_version": "mcss.dynamic.online_episode.v1",
                **identity,
                "declared_length": 12,
                "warmup": [record(frame_id) for frame_id in observed_ids[:4]],
                "stream": [record(frame_id) for frame_id in observed_ids[4:]],
            }
        ),
        encoding="utf-8",
    )
    query_path = tmp_path / "query.json"
    query_path.write_text(
        json.dumps(
            {
                "schema_version": "mcss.dynamic.query_vault.v1",
                **identity,
                "query": [record(frame_id, query=True) for frame_id in query_ids],
            }
        ),
        encoding="utf-8",
    )
    entry = {
        "episode_id": identity["episode_id"],
        "scene_id": identity["scene_id"],
        "split_id": "train",
        "query_vault_id": identity["query_vault_id"],
        "online_manifest": str(online_path),
        "query_manifest": str(query_path),
        "stream_steps": 8,
        "query_count": 4,
    }
    index_path = tmp_path / "episode_index_train.json"
    index_path.write_text(
        json.dumps({"schema_version": "mcss.dynamic.episode_index.v1", "episodes": [entry]}),
        encoding="utf-8",
    )
    return index_path, prepared, entry


def _config() -> DynamicExperimentConfig:
    return DynamicExperimentConfig(
        carrier=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,
        ),
        write=WriteConfig(learning_rate=0.05, max_update_norm=0.2),
        stage_a_steps=2,
        stage_b_steps=2,
        renderer_samples=2,
        ray_chunk_size=64,
        torch_num_threads=1,
    )


def test_two_stage_experiment_writes_checkpoints_history_and_resume(tmp_path) -> None:
    index_path, prepared, _ = _write_fixture(tmp_path)
    output = tmp_path / "training"

    result = run_dynamic_experiment(
        index_path,
        output,
        device="cpu",
        config=_config(),
        prepared_root=prepared,
    )

    assert result["status"] == "complete"
    assert (output / "initial.pt").is_file()
    assert (output / "phase_a_final.pt").is_file()
    assert (output / "phase_b_final.pt").is_file()
    _, _, initial = load_dynamic_checkpoint(output / "initial.pt")
    _, _, phase_a = load_dynamic_checkpoint(output / "phase_a_final.pt")
    _, _, phase_b = load_dynamic_checkpoint(output / "phase_b_final.pt")
    assert initial["phase"] == "initial"
    assert phase_a["phase"] == "phase_a_final"
    assert phase_b["phase"] == "phase_b_final"
    assert initial["provenance"]["training_mode"] == "offline-train-only"

    records = [json.loads(line) for line in (output / "history.jsonl").read_text().splitlines()]
    assert [record["global_step"] for record in records] == [1, 2, 3, 4]
    assert [record["phase"] for record in records] == ["A", "A", "B", "B"]
    assert all(record["action"] == "OFF" for record in records[:2])
    assert all(record["action"] in {"FUSE", "COMPLETE", "ALL"} for record in records[2:])
    assert all(record["valid_depth_fraction"] > 0.0 for record in records)
    assert all(record["write_gradient_norm"] == 0.0 for record in records[:2])
    assert set(records[0]) == {
        "global_step",
        "phase",
        "phase_step",
        "scene_id",
        "query_frame_id",
        "action",
        "loss",
        "terms",
        "carrier_gradient_norm",
        "write_gradient_norm",
        "seconds",
        "valid_depth_fraction",
    }

    resumed = run_dynamic_experiment(
        index_path,
        output,
        device="cpu",
        config=_config(),
        prepared_root=prepared,
        resume=True,
    )
    assert resumed["status"] == "complete"
    assert len((output / "history.jsonl").read_text().splitlines()) == 4


def test_train_index_rejects_dev_before_manifest_loading(tmp_path) -> None:
    index_path = tmp_path / "index.json"
    index_path.write_text(
        json.dumps(
            {
                "schema_version": "mcss.dynamic.episode_index.v1",
                "episodes": [{"episode_id": "dev--unreadable", "split_id": "dev"}],
            }
        ),
        encoding="utf-8",
    )

    with pytest.raises(ValueError, match="non-train"):
        load_train_episode_index(index_path)


def test_experiment_accepts_gapped_but_ordered_frame_ids(tmp_path) -> None:
    observed_ids = (10, 13, 30, 32, 34, 37, 41, 42, 43, 44, 46, 47)
    query_ids = (50, 52, 56, 60)
    index_path, prepared, _ = _write_fixture(
        tmp_path,
        observed_ids=observed_ids,
        query_ids=query_ids,
    )

    result = run_dynamic_experiment(
        index_path,
        tmp_path / "training",
        device="cpu",
        config=DynamicExperimentConfig(
            **{**_config().__dict__, "stage_a_steps": 1, "stage_b_steps": 1}
        ),
        prepared_root=prepared,
    )

    assert result["status"] == "complete"


def test_cpu_cache_sanitizes_invalid_depth_but_preserves_per_query_validity(tmp_path) -> None:
    index_path, _, entry = _write_fixture(tmp_path, all_invalid_query=15)
    entries = load_train_episode_index(index_path)

    cached = CpuEpisodeCache(entries, capacity=1).get(entry["episode_id"])

    assert 0.0 < cached.valid_depth_fractions[0] < 1.0
    assert cached.valid_depth_fractions[3] == 0.0
    assert cached.supervision.query_depth[:, 0].isfinite().all()
    assert (cached.supervision.query_depth[:, 3] == 0).all()


def test_experiment_fails_when_a_selected_query_has_no_valid_depth(tmp_path) -> None:
    index_path, prepared, _ = _write_fixture(tmp_path, all_invalid_query=15)
    output = tmp_path / "training"
    config = DynamicExperimentConfig(
        **{
            **_config().__dict__,
            "stage_a_steps": 4,
            "stage_b_steps": 0,
        }
    )

    with pytest.raises(ValueError, match="selected query frame"):
        run_dynamic_experiment(
            index_path,
            output,
            device="cpu",
            config=config,
            prepared_root=prepared,
        )

    status = json.loads((output / "status.json").read_text())
    assert status["status"] == "failed"


def test_resume_rejects_inconsistent_phase_optimizer_and_counters(tmp_path) -> None:
    index_path, prepared, _ = _write_fixture(tmp_path)
    output = tmp_path / "training"
    config = DynamicExperimentConfig(
        **{**_config().__dict__, "stage_a_steps": 1, "stage_b_steps": 1}
    )
    run_dynamic_experiment(
        index_path,
        output,
        device="cpu",
        config=config,
        prepared_root=prepared,
    )
    payload = torch.load(output / "resume.pt", map_location="cpu", weights_only=True)
    payload["phase"] = "A"
    torch.save(payload, output / "resume.pt")

    with pytest.raises(ValueError, match="phase, optimizer phase, and stage counters"):
        run_dynamic_experiment(
            index_path,
            output,
            device="cpu",
            config=config,
            prepared_root=prepared,
            resume=True,
        )


def test_mixed_schedule_logs_actual_actions_and_resume_is_exact(tmp_path) -> None:
    index_path, prepared, _ = _write_fixture(tmp_path)
    output = tmp_path / "mixed-training"
    config = DynamicExperimentConfig(
        **{
            **_config().__dict__,
            "stage_a_steps": 0,
            "stage_b_steps": 4,
            "action_schedule_mode": "mixed",
            "stage_b_actions": ("OFF", "FUSE", "COMPLETE", "ALL"),
            "stage_b_trajectories": (
                ("OFF", "FUSE", "OFF", "ALL", "OFF", "COMPLETE", "OFF", "ALL"),
                ("ALL", "OFF", "COMPLETE", "OFF", "FUSE", "OFF", "ALL", "OFF"),
            ),
        }
    )

    result = run_dynamic_experiment(
        index_path, output, device="cpu", config=config, prepared_root=prepared
    )
    assert result["status"] == "complete"
    records = [json.loads(line) for line in (output / "history.jsonl").read_text().splitlines()]
    assert len(records) == 4
    assert all(record["phase"] == "B" for record in records)
    assert all(len(record["actions"]) == 8 for record in records)
    assert all(set(record["actions"]) <= {"OFF", "FUSE", "COMPLETE", "ALL"} for record in records)
    assert any(record["action"] == "MIXED" for record in records)
    before = (output / "history.jsonl").read_text()

    resumed = run_dynamic_experiment(
        index_path,
        output,
        device="cpu",
        config=config,
        prepared_root=prepared,
        resume=True,
    )
    assert resumed["status"] == "complete"
    assert (output / "history.jsonl").read_text() == before


def test_init_checkpoint_is_strict_and_recorded_for_a_fresh_run(tmp_path) -> None:
    index_path, prepared, _ = _write_fixture(tmp_path)
    source_output = tmp_path / "source-training"
    source_config = DynamicExperimentConfig(
        **{**_config().__dict__, "stage_a_steps": 0, "stage_b_steps": 0}
    )
    run_dynamic_experiment(
        index_path, source_output, device="cpu", config=source_config, prepared_root=prepared
    )

    output = tmp_path / "warm-start-training"
    result = run_dynamic_experiment(
        index_path,
        output,
        device="cpu",
        config=source_config,
        prepared_root=prepared,
        init_checkpoint=source_output / "phase_b_final.pt",
    )
    assert result["status"] == "complete"
    metadata = json.loads((output / "run_metadata.json").read_text())
    assert metadata["initial_checkpoint_sha256"]
    resume = torch.load(output / "resume.pt", map_location="cpu", weights_only=True)
    assert resume["initial_checkpoint_sha256"] == metadata["initial_checkpoint_sha256"]

    mismatched = DynamicExperimentConfig(
        **{
            **source_config.__dict__,
            "carrier": CarrierConfig(
                feature_dim=5,
                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,
            ),
        }
    )
    with pytest.raises(ValueError, match="CarrierConfig/WriteConfig"):
        run_dynamic_experiment(
            index_path,
            tmp_path / "mismatch-training",
            device="cpu",
            config=mismatched,
            prepared_root=prepared,
            init_checkpoint=source_output / "phase_b_final.pt",
        )