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

tests/dynamic/test_sealed_rgb.py

"""Dynamic contract tests for the sealed RGB-only evaluator."""

from __future__ import annotations

import math
from dataclasses import replace

import pytest
import torch

from mcss.dynamic.types import SealedScene, hash_scene_state
from mcss.evaluation.sealed_rgb import (
    RGBMetric,
    RGBQuery,
    evaluate_sealed_rgb,
    official_ssim_metric,
    psnr_full_image,
)
from mcss.types import Cameras, SceneState


def test_invalid_seal_rejects_queries_before_supplier_access() -> None:
    sealed = _sealed_scene()
    sealed = replace(sealed, state_hash="poisoned")
    calls: list[str] = []
    query = _query(
        sealed,
        frame_id=3,
        camera_supplier=lambda: calls.append("camera") or _camera(),
        rgb_supplier=lambda: calls.append("rgb") or torch.zeros(3, 8, 8),
    )

    with pytest.raises(ValueError, match="state_hash"):
        evaluate_sealed_rgb(sealed, [query], _rgb_renderer)

    assert calls == []


def test_query_preprocessor_runs_after_seal_validation_and_before_suppliers() -> None:
    sealed = _sealed_scene()
    events: list[str] = []

    def preprocess(query: RGBQuery) -> RGBQuery:
        events.append("preprocess")
        return query

    query = _query(
        sealed,
        frame_id=3,
        camera_supplier=lambda: events.append("camera") or _camera(),
        rgb_supplier=lambda: events.append("rgb") or torch.zeros(3, 8, 8),
    )
    evaluate_sealed_rgb(
        sealed,
        [query],
        _rgb_renderer,
        query_preprocessor=preprocess,
    )

    assert events == ["preprocess", "camera", "rgb"]

    poisoned = replace(sealed, state_hash="poisoned")
    events.clear()
    with pytest.raises(ValueError, match="state_hash"):
        evaluate_sealed_rgb(
            poisoned,
            [query],
            _rgb_renderer,
            query_preprocessor=lambda item: events.append("preprocess") or item,
        )
    assert events == []


@pytest.mark.parametrize("kind", ["scene", "episode", "split", "vault"])
def test_identity_mismatch_rejects_before_supplier_access(kind: str) -> None:
    sealed = _sealed_scene()
    calls: list[str] = []
    values = {
        "episode": sealed.episode_id,
        "scene": sealed.scene_id,
        "split": sealed.split_id,
        "vault": sealed.query_vault_id,
    }
    values[kind] = f"other-{kind}"
    query = RGBQuery(
        3,
        values["episode"],
        values["scene"],
        values["split"],
        values["vault"],
        lambda: calls.append("camera") or _camera(),
        lambda: calls.append("rgb") or torch.zeros(3, 8, 8),
        (8, 8),
    )

    with pytest.raises(ValueError, match="does not match sealed scene"):
        evaluate_sealed_rgb(sealed, [query], _rgb_renderer)

    assert calls == []


def test_query_overlap_rejects_before_supplier_access() -> None:
    sealed = _sealed_scene()
    calls: list[str] = []
    query = _query(
        sealed,
        frame_id=1,
        camera_supplier=lambda: calls.append("camera") or _camera(),
        rgb_supplier=lambda: calls.append("rgb") or torch.zeros(3, 8, 8),
    )

    with pytest.raises(ValueError, match="disjoint"):
        evaluate_sealed_rgb(sealed, [query], _rgb_renderer)

    assert calls == []


def test_target_supplier_mutation_is_rejected_and_original_state_restored() -> None:
    sealed = _sealed_scene()
    before = hash_scene_state(sealed.scene_state)

    def mutate_target() -> torch.Tensor:
        sealed.scene_state.color.add_(1.0)
        return torch.zeros(3, 8, 8)

    query = _query(sealed, frame_id=3, rgb_supplier=mutate_target)
    with pytest.raises(RuntimeError, match="sealed scene_state"):
        evaluate_sealed_rgb(sealed, [query], _rgb_renderer)

    assert hash_scene_state(sealed.scene_state) == before


def test_anchor_is_applied_once_and_renderer_receives_rgb_only() -> None:
    sealed = _sealed_scene()
    sealed.anchor_c2w[0, 3] = 1.0
    captured: dict[str, object] = {}
    world_camera = _camera()
    world_camera.c2w[0, 3] = 3.0

    def render(state: SceneState, cameras: Cameras, measurements: set[str]):
        captured["c2w"] = cameras.c2w.detach().clone()
        captured["measurements"] = measurements
        return {"rgb": torch.zeros(1, 1, 3, 8, 8)}

    query = _query(
        sealed,
        frame_id=3,
        camera_supplier=lambda: world_camera,
        rgb_supplier=lambda: torch.zeros(3, 8, 8),
    )
    result = evaluate_sealed_rgb(sealed, [query], render)

    assert captured["measurements"] == {"rgb"}
    assert captured["c2w"][0, 0, 0, 3].item() == pytest.approx(2.0)
    assert "depth" not in result["per_image"][0]


def test_psnr_uses_exact_full_unmasked_rgb_mse() -> None:
    prediction = torch.zeros(3, 8, 8)
    target = torch.full((3, 8, 8), 0.5)

    assert psnr_full_image(prediction, target) == pytest.approx(6.0205999133)
    assert math.isinf(psnr_full_image(target, target))
    assert psnr_full_image(torch.full_like(target, 2.0), target) == pytest.approx(
        6.0205999133
    )


def test_ssim_matches_official_skimage_call() -> None:
    skimage = pytest.importorskip("skimage.metrics")
    torch.manual_seed(4)
    prediction = torch.rand(3, 8, 8)
    target = torch.rand(3, 8, 8)
    expected = skimage.structural_similarity(
        prediction.numpy(), target.numpy(), channel_axis=0, data_range=1.0
    )

    metric = official_ssim_metric()
    assert metric.evaluate(prediction, target) == pytest.approx(expected)
    assert metric.full_resolution
    assert "channel_axis=0" in metric.preprocessing


def test_custom_metric_receives_one_full_resolution_image_per_query() -> None:
    seen: list[tuple[tuple[int, ...], tuple[int, ...]]] = []

    def metric(prediction: torch.Tensor, target: torch.Tensor) -> float:
        seen.append((tuple(prediction.shape), tuple(target.shape)))
        return float((prediction - target).abs().mean())

    sealed = _sealed_scene()
    queries = [_query(sealed, frame_id=3), _query(sealed, frame_id=4)]
    result = evaluate_sealed_rgb(
        sealed,
        queries,
        _rgb_renderer,
        metrics={"mae": RGBMetric("mae", metric)},
    )

    assert seen == [((3, 8, 8), (3, 8, 8)), ((3, 8, 8), (3, 8, 8))]
    assert len(result["per_image"]) == 2
    assert "averages" not in result


def _sealed_scene() -> SealedScene:
    state = SceneState(
        density_logits=torch.ones(1, 1, 2, 2, 2),
        color=torch.full((1, 3, 2, 2, 2), 0.5),
        log_variance=torch.zeros(1, 1, 2, 2, 2),
        bounds=torch.tensor([[[-1.0, -1.0, 0.1], [1.0, 1.0, 1.0]]]),
    )
    return SealedScene(
        episode_id="episode-a",
        scene_id="scene-a",
        split_id="dev",
        query_vault_id="vault-a",
        scene_state=state,
        state_hash=hash_scene_state(state),
        observed_ids=(0, 1, 2),
        checkpoint_hash="checkpoint-a",
        config_hash="config-a",
        fast_state_hash="fast-a",
        anchor_c2w=torch.eye(4),
    )


def _query(
    sealed: SealedScene,
    *,
    frame_id: int,
    camera_supplier=lambda: _camera(),
    rgb_supplier=lambda: torch.full((3, 8, 8), 0.5),
) -> RGBQuery:
    return RGBQuery.for_sealed(
        sealed,
        frame_id,
        camera_supplier,
        rgb_supplier,
        image_size=(8, 8),
    )


def _camera() -> Cameras:
    return Cameras(
        torch.tensor([[8.0, 0.0, 4.0], [0.0, 8.0, 4.0], [0.0, 0.0, 1.0]]),
        torch.eye(4),
        (8, 8),
    )


def _rgb_renderer(
    state: SceneState, cameras: Cameras, measurements: set[str] | None = None
) -> dict[str, torch.Tensor]:
    assert measurements in (None, {"rgb"})
    return {"rgb": torch.zeros(1, 1, 3, 8, 8)}