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")