Zum Inhalt

pipeline/tests/test_quality.py

# SPDX-License-Identifier: AGPL-3.0-or-later

"""Phase-3 quality-gate tests — thresholds, retry hints, exception payload."""

from __future__ import annotations

from pathlib import Path

import pytest

from autosplat.config import ColmapConfig, QualityGateConfig
from autosplat.quality import (
    QualityGateFailure,
    check_sfm_quality,
    evaluate_sfm,
    retry_hint_for_brush_oom,
)
from autosplat.sfm import SfmResult


def _sfm(cams: int, points: int) -> SfmResult:
    return SfmResult(
        workspace=Path("/tmp"),
        database_path=Path("/tmp/db.db"),
        sparse_dir=Path("/tmp/sparse"),
        cameras_registered=cams,
        points=points,
        duration_s=10.0,
    )


def _cfg(min_ratio: float = 0.5, min_points: int = 5000) -> QualityGateConfig:
    return QualityGateConfig(enabled=True, min_camera_ratio=min_ratio, min_points=min_points)


def _seq() -> ColmapConfig:
    return ColmapConfig(matcher="sequential", quality="medium", single_camera=True)


def _exh() -> ColmapConfig:
    return ColmapConfig(matcher="exhaustive", quality="medium", single_camera=True)


# ─── evaluate_sfm — pure helper ─────────────────────────────────────────────


def test_evaluate_passes_when_above_thresholds() -> None:
    result = evaluate_sfm(_sfm(cams=80, points=10000), frames_kept=100, cfg=_cfg())
    assert result.ok is True
    assert result.ratio == pytest.approx(0.8)
    assert result.points == 10000


def test_evaluate_fails_on_low_ratio() -> None:
    # bench_chill: 107/107 = 1.0 ✓ but ice_bird-style 4/106 = 0.038 ✗
    result = evaluate_sfm(_sfm(cams=4, points=10000), frames_kept=106, cfg=_cfg())
    assert result.ok is False
    assert "low_camera_ratio" in (result.reason or "")


def test_evaluate_fails_on_low_points() -> None:
    # High ratio but tiny sparse cloud
    result = evaluate_sfm(_sfm(cams=80, points=500), frames_kept=100, cfg=_cfg(min_points=5000))
    assert result.ok is False
    assert "low_points" in (result.reason or "")


def test_evaluate_fails_when_no_frames_kept() -> None:
    result = evaluate_sfm(_sfm(cams=0, points=0), frames_kept=0, cfg=_cfg())
    assert result.ok is False
    assert result.reason == "no_frames_kept"


# ─── check_sfm_quality — raises on fail ────────────────────────────────────


def test_check_disabled_is_noop() -> None:
    cfg = QualityGateConfig(enabled=False, min_camera_ratio=0.99, min_points=99_999_999)
    check_sfm_quality(_sfm(0, 0), frames_kept=0, cfg=cfg, colmap_cfg=_seq())  # no raise


def test_check_passes_for_good_sfm() -> None:
    check_sfm_quality(
        _sfm(cams=107, points=53_000), frames_kept=107, cfg=_cfg(), colmap_cfg=_seq()
    )  # no raise


def test_check_raises_on_low_ratio_with_exhaustive_hint_for_sequential() -> None:
    # When matcher was sequential, the retry hint should suggest exhaustive.
    with pytest.raises(QualityGateFailure) as excinfo:
        check_sfm_quality(
            _sfm(cams=4, points=10000), frames_kept=106, cfg=_cfg(), colmap_cfg=_seq()
        )
    err = excinfo.value
    assert err.stage == "sfm_validation"
    assert err.retry_hint == {"colmap": {"matcher": "exhaustive"}}
    assert err.metrics["cameras_registered"] == 4
    assert err.metrics["matcher"] == "sequential"


def test_check_raises_on_low_ratio_without_hint_when_already_exhaustive() -> None:
    # Already on exhaustive — no further matcher we can swap to.
    with pytest.raises(QualityGateFailure) as excinfo:
        check_sfm_quality(
            _sfm(cams=4, points=10000), frames_kept=106, cfg=_cfg(), colmap_cfg=_exh()
        )
    assert excinfo.value.retry_hint is None


def test_check_raises_on_low_points_without_hint() -> None:
    # No matcher swap will rescue a texture-poor scene; hint is None on purpose.
    with pytest.raises(QualityGateFailure) as excinfo:
        check_sfm_quality(
            _sfm(cams=80, points=500), frames_kept=100, cfg=_cfg(min_points=5000), colmap_cfg=_seq()
        )
    assert excinfo.value.retry_hint is None
    assert "low_points" in excinfo.value.reason


# ─── User-facing capture-guide hint (str(exc) appears in CLI + JobState.error)


def test_failure_str_points_to_capture_guide_when_no_retry_hint_remains() -> None:
    """Terminal failures (matcher already exhaustive, or low_points) carry a
    pointer to docs/CAPTURE-GUIDE.md in their stringified form so users see
    the actionable advice in both the CLI traceback and the WebUI error."""
    with pytest.raises(QualityGateFailure) as excinfo:
        check_sfm_quality(
            _sfm(cams=4, points=10000), frames_kept=106, cfg=_cfg(), colmap_cfg=_exh()
        )
    msg = str(excinfo.value)
    assert "low_camera_ratio" in msg
    assert "CAPTURE-GUIDE" in msg


def test_failure_str_omits_capture_guide_hint_when_retry_will_run() -> None:
    """When a retry hint is set (sequential → exhaustive), the matcher swap
    will likely fix it on its own — don't preemptively scold the user for
    their footage. Suppress the capture-guide hint."""
    with pytest.raises(QualityGateFailure) as excinfo:
        check_sfm_quality(
            _sfm(cams=4, points=10000), frames_kept=106, cfg=_cfg(), colmap_cfg=_seq()
        )
    msg = str(excinfo.value)
    assert "low_camera_ratio" in msg
    assert "CAPTURE-GUIDE" not in msg


# ─── retry_hint_for_brush_oom (Phase 6 / Spec §9.2) ─────────────────────────


def test_brush_oom_retry_hint_halves_resolution() -> None:
    assert retry_hint_for_brush_oom(1600) == {"brush": {"resolution_cap": 800}}
    assert retry_hint_for_brush_oom(800) == {"brush": {"resolution_cap": 400}}


def test_brush_oom_retry_hint_clamps_to_minimum() -> None:
    # Pydantic BrushConfig has ge=256 for resolution_cap, so we clamp there.
    assert retry_hint_for_brush_oom(300) == {"brush": {"resolution_cap": 256}}
    assert retry_hint_for_brush_oom(100) == {"brush": {"resolution_cap": 256}}