File size: 7,892 Bytes
729628a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
"""Strict, deterministic, configurable triage gate (spec §19-§21).

Combines ML anomaly score, regression status, grader consensus, geometric
validation and track validation under a quality policy. Auto-release only when
ALL applicable gates pass. Every decision stores the policy id + values and
per-gate lines with actual vs threshold (explainability, spec §39).
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Dict, List, Optional

from pydantic import BaseModel, Field

from sensorflow.evaluation.records import (
    Annotation,
    AnomalyDetection,
    CheckLine,
    EvalStore,
    GraderComparison,
    TrackingEvidence,
    TriageDecision,
    ValidationResult,
    new_id,
)


class QualityPolicy(BaseModel):
    policy_id: str = "quality-policy-v1"
    min_iou_3d: float = 0.80
    max_position_error_m: float = 0.50
    max_orientation_error_deg: float = 10.0
    min_points_in_box: int = 12
    min_consensus: float = 0.90
    max_anomaly_score: float = 0.90
    min_track_quality: float = 0.60
    min_confidence: float = 0.40
    require_sensor_consistency: bool = True
    require_geometric_validation: bool = True
    block_on_model_regression: bool = False

    def values(self) -> Dict[str, float]:
        return {k: (float(v) if not isinstance(v, bool) else float(v))
                for k, v in self.model_dump().items() if k != "policy_id"}


POLICY_PATH = Path("runs/labeleval/quality_policy.json")


def load_policy(path: Path = POLICY_PATH) -> QualityPolicy:
    if path.exists():
        try:
            with open(path) as f:
                return QualityPolicy.model_validate(json.load(f))
        except Exception:
            pass
    return QualityPolicy()


def save_policy(policy: QualityPolicy, path: Path = POLICY_PATH) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with open(path, "w") as f:
        json.dump(policy.model_dump(), f, indent=2)


# Gate failure -> failure reason mapping (spec §21).
def triage_annotation(
    store: EvalStore,
    ann: Annotation,
    validation: Optional[ValidationResult],
    anomaly: Optional[AnomalyDetection],
    grading: Optional[GraderComparison],
    tracking: Optional[TrackingEvidence],
    policy: QualityPolicy,
    model_regressed: bool = False,
) -> TriageDecision:
    lines: List[CheckLine] = []
    reasons: List[str] = []

    def gate(name: str, actual, threshold, passed: bool, applicable: bool = True, reason: Optional[str] = None):
        lines.append(CheckLine(gate=name, actual=actual, threshold=threshold,
                               passed=passed or not applicable, applicable=applicable))
        if applicable and not passed and reason and reason not in reasons:
            reasons.append(reason)

    # --- geometric gates (only applicable when a reference exists)
    has_ref = validation is not None and validation.iou_3d is not None
    gate("iou_3d", validation.iou_3d if has_ref else "no reference", policy.min_iou_3d,
         bool(has_ref and validation.iou_3d >= policy.min_iou_3d), applicable=has_ref, reason="LOW_IOU")
    gate("position_error_m", validation.position_error if has_ref else "no reference",
         policy.max_position_error_m,
         bool(has_ref and validation.position_error <= policy.max_position_error_m),
         applicable=has_ref, reason="POSITION_ERROR")
    gate("orientation_error_deg", validation.orientation_error_deg if has_ref else "no reference",
         policy.max_orientation_error_deg,
         bool(has_ref and validation.orientation_error_deg <= policy.max_orientation_error_deg),
         applicable=has_ref, reason="ORIENTATION_ERROR")

    # Unmatched labels (no reference GT at all) are inherently suspect: the
    # independent evidence must carry them; flag as anomaly-checked FP risk.
    if ann.matched_gt_id is None:
        gate("has_reference_gt", False, True, False, applicable=True, reason="ANOMALY")

    # --- point support
    n_pts = None
    if validation is not None:
        pts_line = next((c for c in validation.checks if c.gate == "points_in_box"), None)
        n_pts = pts_line.actual if pts_line else None
    gate("points_in_box", n_pts, policy.min_points_in_box,
         bool(isinstance(n_pts, (int, float)) and n_pts >= policy.min_points_in_box),
         applicable=n_pts is not None, reason="INSUFFICIENT_POINT_SUPPORT")

    # --- geometric validation verdict; map each failing check to its reason
    CHECK_REASONS = {
        "points_in_box": "INSUFFICIENT_POINT_SUPPORT",
        "point_density_per_m3": "INSUFFICIENT_POINT_SUPPORT",
        "box_occupancy": "INSUFFICIENT_POINT_SUPPORT",
        "iou_3d_vs_reference": "LOW_IOU",
        "position_error_m": "POSITION_ERROR",
        "centroid_consistency_m": "POSITION_ERROR",
        "ground_contact_error_m": "POSITION_ERROR",
        "orientation_error_deg": "ORIENTATION_ERROR",
        "box_dimensions_vs_class_prior": "LOW_IOU",
        "dimension_error": "LOW_IOU",
        "sensor_consistency_cam_lidar": "SENSOR_DISAGREEMENT",
    }
    if validation is not None and not validation.passed:
        for c in validation.checks:
            if c.applicable and not c.passed:
                r = CHECK_REASONS.get(c.gate)
                if r and r not in reasons:
                    reasons.append(r)
    gate("geometric_validation", "passed" if (validation and validation.passed) else "failed",
         "passed", bool(validation and validation.passed),
         applicable=policy.require_geometric_validation, reason=None)

    # --- sensor consistency
    gate("sensor_consistency", "consistent" if (validation is None or validation.sensor_consistent) else "disagreement",
         "consistent", bool(validation is None or validation.sensor_consistent),
         applicable=policy.require_sensor_consistency and validation is not None, reason="SENSOR_DISAGREEMENT")

    # --- anomaly
    gate("anomaly_score", round(anomaly.score, 4) if anomaly else None, policy.max_anomaly_score,
         bool(anomaly is None or not anomaly.is_anomaly),
         applicable=anomaly is not None, reason="ANOMALY")

    # --- grader consensus
    gate("grader_consensus", grading.consensus if grading else None, policy.min_consensus,
         bool(grading is not None and grading.consensus is not None and grading.consensus >= policy.min_consensus),
         applicable=grading is not None, reason="GRADER_DISAGREEMENT")

    # --- tracking
    if tracking is not None:
        gate("id_switch", tracking.id_switch, False, not tracking.id_switch, reason="ID_SWITCH")
        gate("track_fragmentation", tracking.fragmentation, False, not tracking.fragmentation,
             reason="TRACK_FRAGMENTATION")
        gate("track_quality", tracking.track_quality, policy.min_track_quality,
             bool(tracking.track_quality is None or tracking.track_quality >= policy.min_track_quality),
             applicable=tracking.track_quality is not None, reason="TRACK_FRAGMENTATION")

    # --- confidence
    gate("detection_confidence", ann.confidence, policy.min_confidence,
         ann.confidence >= policy.min_confidence, reason="LOW_CONFIDENCE")

    # --- model regression
    gate("model_regression", "regressed" if model_regressed else "stable", "stable",
         not model_regressed, applicable=policy.block_on_model_regression, reason="MODEL_REGRESSION")

    all_pass = all(l.passed for l in lines if l.applicable)
    status = "AUTO_GRADED" if all_pass else "FLAGGED"

    decision = TriageDecision(
        decision_id=new_id("dec"),
        annotation_id=ann.annotation_id,
        status=status,
        failure_reasons=reasons,
        primary_failure_reason=reasons[0] if reasons else None,
        gate_lines=lines,
        policy_id=policy.policy_id,
        policy_values=policy.values(),
    )
    store.put("triage_decisions", decision)
    return decision