Source: tests/test_small_3d_training.py · Plain text

tests/test_small_3d_training.py

import json
from copy import deepcopy

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

from mcss.dynamic.checkpoint import load_dynamic_checkpoint
from mcss.mechanism_pilot.small_training import (
    SmallTrainingConfig,
    TrainScene,
    run_small_training,
    schedule,
    validate_manifest,
)


def manifest_fixture(tmp_path):
    scenes = []
    for i in range(3):
        frames = []
        for frame_id in range(16):
            rgb = tmp_path / f"{i}_{frame_id}.png"
            depth = tmp_path / f"{i}_{frame_id}.npy"
            Image.fromarray(np.full((8, 8, 3), 80 + i * 10, dtype=np.uint8)).save(rgb)
            np.save(depth, np.full((8, 8), 3.0, dtype=np.float32))
            frames.append(
                {
                    "frame_id": frame_id,
                    "rgb": str(rgb),
                    "depth": str(depth),
                    "intrinsics": [[8.0, 0.0, 4.0], [0.0, 8.0, 4.0], [0.0, 0.0, 1.0]],
                    "c2w": np.eye(4).tolist(),
                }
            )
        scenes.append(
            {
                "scene_id": f"fixture{i}",
                "split": "train",
                "frames": frames,
                "roles": {
                    "context_a": [0, 1, 2],
                    "context_b": [0, 3, 4],
                    "stream": [5, 6],
                    "query": [12, 13, 14, 15],
                },
            }
        )
    return {"schema": "mcss.small_training.data.v1", "image_size": [8, 8], "scenes": scenes}


def test_schedule_does_not_confound_scene_action():
    per_scene = {i: set() for i in range(3)}
    for step in range(36):
        scene, query, action = schedule(step, 3)
        per_scene[scene].add((query, action))
    assert all(len(pairs) == 12 for pairs in per_scene.values())


def test_train_manifest_rejects_dev_query_reuse_and_duplicates(tmp_path):
    manifest = manifest_fixture(tmp_path)
    validate_manifest(manifest, engineering_fixture=True)
    bad = deepcopy(manifest)
    bad["scenes"][0]["split"] = "dev"
    with pytest.raises(ValueError, match="train scenes"):
        validate_manifest(bad, engineering_fixture=True)
    bad = deepcopy(manifest)
    bad["scenes"][0]["roles"]["query"][0] = 0
    with pytest.raises(ValueError, match="Frame roles"):
        validate_manifest(bad, engineering_fixture=True)
    bad = deepcopy(manifest)
    bad["scenes"][1]["scene_id"] = bad["scenes"][0]["scene_id"]
    with pytest.raises(ValueError, match="distinct"):
        validate_manifest(bad, engineering_fixture=True)
    scene = TrainScene(manifest["scenes"][0], (8, 8), tmp_path, "cpu", [])
    with pytest.raises(ValueError, match="cannot construct"):
        scene.observations("query")


@pytest.mark.parametrize("spatial_mode", ["legacy-forward", "anchor-centered"])
def test_two_stage_training_roundtrip_and_label_contract(tmp_path, spatial_mode):
    from mcss.mechanism_pilot.spatial import carrier_config_for_spatial_mode

    carrier_config = carrier_config_for_spatial_mode(spatial_mode)
    torch.set_num_threads(1)
    manifest = manifest_fixture(tmp_path)
    path = tmp_path / "manifest.json"
    path.write_text(json.dumps(manifest))
    output = tmp_path / "run"
    summary = run_small_training(
        path,
        output,
        device="cpu",
        engineering_fixture=True,
        carrier_config=carrier_config,
        config=SmallTrainingConfig(
            stage_a_steps=1, stage_b_steps=1, renderer_samples=4, ray_chunk_size=64
        ),
    )
    assert summary["source_unchanged"] and summary["data_unchanged"]
    assert summary["n_dev_scenes"] == summary["n_test_scenes"] == 0
    assert len(json.loads((output / "train_evaluation.json").read_text())) == 36
    initial, initial_writer, _ = load_dynamic_checkpoint(output / "initial.pt")
    final, final_writer, _ = load_dynamic_checkpoint(output / "phase_b_final.pt")
    assert initial.config == final.config == carrier_config
    assert json.loads((output / "lock.json").read_text())["carrier"]["local_bounds_m"] == [
        list(v) for v in carrier_config.local_bounds_m
    ]
    assert any(not torch.equal(v, final.state_dict()[k]) for k, v in initial.state_dict().items())
    assert any(
        not torch.equal(v, final_writer.state_dict()[k])
        for k, v in initial_writer.state_dict().items()
    )
    rows = [json.loads(line) for line in (output / "training.jsonl").read_text().splitlines()]
    assert len(rows) == 2 and rows[1]["writer_gradient_norm_after_clip"] > 0
    access = json.loads((output / "label_access.json").read_text())
    assert all(row["split"] == "train" for row in access)
    assert all(row["frame_id"] >= 12 for row in access if "depth" in row["kind"])
    with pytest.raises(FileExistsError):
        run_small_training(path, output, device="cpu")