from __future__ import annotations import json from pathlib import Path import pytest from k1link.perception.m48t_risk_quality import ( CocoRiskImage, M48TRiskQualityError, RiskPrediction, RiskTruth, load_coco_risk_truth, load_m48t_risk_quality_profile, score_risk_quality, ) REPOSITORY_ROOT = Path(__file__).resolve().parents[1] PROFILE_PATH = REPOSITORY_ROOT / "config/perception/m48t-risk-quality-temporal-v1.json" def _truth( annotation_id: int, class_name: str, family: str, bbox: tuple[float, float, float, float], *, size_band: str = "medium", ) -> RiskTruth: return RiskTruth( image_id=1, annotation_id=annotation_id, class_name=class_name, family=family, bbox_xyxy=bbox, projected_area_pixels=(bbox[2] - bbox[0]) * (bbox[3] - bbox[1]), size_band=size_band, ) def _prediction( prediction_id: str, class_name: str, family: str, bbox: tuple[float, float, float, float], score: float = 0.9, ) -> RiskPrediction: return RiskPrediction( image_id=1, prediction_id=prediction_id, class_name=class_name, family=family, score=score, bbox_xyxy=bbox, ) def test_m48t_profile_pins_candidate_and_class_independent_temporal_policy() -> None: profile = load_m48t_risk_quality_profile(PROFILE_PATH) assert profile.minimum_score == 0.25 assert profile.model_id == "rf_detr_large:1" assert profile.class_to_family["dog"] == "animal" assert profile.temporal.initial_confirmation_observations == 2 assert profile.temporal.switch_confirmation_observations == 3 def test_m48t_profile_rejects_candidate_threshold_tuning(tmp_path: Path) -> None: document = json.loads(PROFILE_PATH.read_text("utf-8")) document["candidate"]["minimum_score"] = 0.2 changed = tmp_path / "changed.json" changed.write_text(json.dumps(document), "utf-8") with pytest.raises(M48TRiskQualityError, match="tuned in place"): load_m48t_risk_quality_profile(changed) def test_coco_truth_is_projected_to_actual_source_contract_and_filtered( tmp_path: Path, ) -> None: profile = load_m48t_risk_quality_profile(PROFILE_PATH) annotations = { "images": [{"id": 1, "file_name": "one.jpg", "width": 400, "height": 300}], "categories": [ {"id": index, "name": class_name} for index, class_name in enumerate(profile.class_to_family, start=1) ], "annotations": [ {"id": 1, "image_id": 1, "category_id": 1, "bbox": [10, 20, 20, 30], "iscrowd": 0}, {"id": 2, "image_id": 1, "category_id": 1, "bbox": [1, 1, 1, 1], "iscrowd": 0}, {"id": 3, "image_id": 1, "category_id": 1, "bbox": [10, 20, 20, 30], "iscrowd": 1}, ], } path = tmp_path / "instances.json" path.write_text(json.dumps(annotations), "utf-8") images, truth = load_coco_risk_truth(path, profile) assert images == (CocoRiskImage(image_id=1, file_name="one.jpg", width=400, height=300),) assert len(truth) == 1 assert truth[0].bbox_xyxy == (20.0, 40.0, 60.0, 100.0) assert truth[0].projected_area_pixels == 2400.0 def test_quality_separates_exact_class_family_and_failure_buckets() -> None: profile = load_m48t_risk_quality_profile(PROFILE_PATH) images = (CocoRiskImage(image_id=1, file_name="one.jpg", width=800, height=600),) truth = ( _truth(1, "person", "person", (10.0, 10.0, 110.0, 210.0), size_band="large"), _truth(2, "dog", "animal", (200.0, 100.0, 260.0, 170.0)), _truth(3, "car", "vehicle", (400.0, 200.0, 600.0, 350.0), size_band="large"), _truth(4, "bicycle", "light-road-user", (650.0, 200.0, 760.0, 350.0)), ) predictions = ( _prediction("p1", "person", "person", (10.0, 10.0, 110.0, 210.0)), _prediction("p2", "cat", "animal", (200.0, 100.0, 260.0, 170.0)), _prediction("p3", "car", "vehicle", (520.0, 300.0, 700.0, 450.0)), _prediction("p4", "truck", "vehicle", (300.0, 20.0, 390.0, 100.0)), ) result = score_risk_quality( images=images, truth=truth, predictions=predictions, profile=profile, ) assert result.report["counts"] == { "predictions": 4, "true_positive": 1, "false_positive": 3, "false_negative": 3, "empty_prediction_risk_images": 0, } metrics = result.report["metrics"] assert isinstance(metrics, dict) families = metrics["families"] assert isinstance(families, dict) assert families["animal"]["exact_class_recall"] == 0.0 assert families["animal"]["family_recall"] == 1.0 buckets = result.report["failure_buckets"] assert buckets["same-family-class-confusion"] == 1 assert buckets["localization"] == 1 assert buckets["missed"] == 1 assert buckets["unmatched-prediction"] == 3 assert result.report["quality_gates"]["passed"] is False assert result.report["authority"]["candidate_accepted"] is False def test_quality_rejects_predictions_below_frozen_threshold() -> None: profile = load_m48t_risk_quality_profile(PROFILE_PATH) images = (CocoRiskImage(image_id=1, file_name="one.jpg", width=800, height=600),) with pytest.raises(M48TRiskQualityError, match="escaped the frozen score"): score_risk_quality( images=images, truth=(_truth(1, "person", "person", (10.0, 10.0, 100.0, 200.0)),), predictions=( _prediction( "p1", "person", "person", (10.0, 10.0, 100.0, 200.0), score=0.24, ), ), profile=profile, )