File size: 9,498 Bytes
e1c927b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
"""Evaluation flywheel: confirmed violation -> failure signature -> cluster
-> HITL validation -> regression-test artifact, under dataset-role
governance with a contamination guard.

Roles are immutable once assigned; members of PROTECTED roles (REGRESSION,
LAUNCH) are NEVER training-eligible without a recorded governance override
— semantics mirrored from raremine.lineage (ContaminationError subclasses
raremine's LeakageError when importable so callers can catch either).
`sensorflow.studio2`'s registry is used when it lands (guarded import,
re-checked at integration time); until then the local implementation
persists to runs/rotr/.
"""

from __future__ import annotations

import uuid
from typing import Dict, List, Optional

from sensorflow.rotr import SOFTWARE_VERSION
from sensorflow.rotr import store
from sensorflow.rotr.models import (
    HITLReview, PROTECTED_ROLES, Provenance, TrainingCandidate, now_iso,
)

FLYWHEEL_VERSION = "rotr-flywheel-1.0.0"

# Mirror raremine's guard exception when importable (landed package,
# read-only reuse) so platform callers can catch one exception type.
try:
    from sensorflow.raremine.lineage import LeakageError as _LeakageBase
except Exception:                                     # pragma: no cover
    _LeakageBase = RuntimeError


class ContaminationError(_LeakageBase):
    """Raised when a protected evaluation member would leak into training."""


# Registry backend: studio2 when it lands, else local (recorded on artifacts).
# The local store is ALWAYS written (the guard never depends on the
# concurrent workstream); studio2 registration is a best-effort mirror.
try:
    from sensorflow.studio2.registry import get_registry as _studio2_get_registry
    REGISTRY_BACKEND = "studio2+local"
except Exception:
    _studio2_get_registry = None
    REGISTRY_BACKEND = "local"


def _mirror_to_studio2(candidate: "TrainingCandidate") -> Optional[str]:
    """Best-effort mirror of the regression artifact into the studio2
    entity registry (guarded: studio2 is a concurrent workstream)."""
    if _studio2_get_registry is None:
        return None
    try:
        entity = _studio2_get_registry().register_dataset(
            name=f"rotr-regression-{candidate.candidate_id}",
            role="REGRESSION",
            provenance=candidate.provenance.model_dump(),
            meta={"violation_id": candidate.violation_id,
                  "cluster_id": candidate.cluster_id,
                  "run_id": candidate.run_id, "source": "sensorflow.rotr"},
            actor="rotr-flywheel")
        return entity.get("entity_id")
    except Exception:
        return None


def _audit(event: str, subject: str, actor: str, detail: str = "") -> None:
    doc = store.read_json("flywheel", "audit.json") or {"events": []}
    doc["events"].append({"at": now_iso(), "event": event, "subject": subject,
                          "actor": actor, "detail": detail})
    store.write_json(doc, "flywheel", "audit.json")


# ------------------------------------------------------------ HITL queue


def build_queue(run_id: str, violations: List[Dict],
                clusters: List[Dict]) -> List[HITLReview]:
    vid_to_cluster = {}
    for c in clusters:
        for vid in c["member_violation_ids"]:
            vid_to_cluster[vid] = c["cluster_id"]
    reviews = []
    for v in violations:
        rid = f"rev-{v['violation_id']}"
        reviews.append(HITLReview(
            review_id=rid, run_id=run_id, violation_id=v["violation_id"],
            cluster_id=vid_to_cluster.get(v["violation_id"]),
            status="PENDING",
            provenance=Provenance(
                scenario_id=v.get("scenario_id"),
                software_version=f"{SOFTWARE_VERSION}/{FLYWHEEL_VERSION}",
                source="SYNTHETIC")))
    store.write_json({"run_id": run_id,
                      "reviews": [r.model_dump() for r in reviews]},
                     "flywheel", f"queue-{run_id}.json")
    return reviews


def get_queue(run_id: str) -> List[Dict]:
    doc = store.read_json("flywheel", f"queue-{run_id}.json")
    return doc["reviews"] if doc else []


def act(run_id: str, review_id: str, action: str, actor: str,
        notes: str = "") -> Dict:
    """VALIDATE turns a confirmed violation into a regression-test artifact
    (protected role, not training-eligible). REJECT closes the review."""
    doc = store.read_json("flywheel", f"queue-{run_id}.json")
    if not doc:
        raise KeyError(f"no HITL queue for run {run_id}")
    review = next((r for r in doc["reviews"] if r["review_id"] == review_id),
                  None)
    if review is None:
        raise KeyError(f"unknown review {review_id}")
    if review["status"] != "PENDING":
        raise ValueError(f"review {review_id} already {review['status']}")

    action = action.upper()
    if action not in ("VALIDATE", "REJECT"):
        raise ValueError("action must be VALIDATE or REJECT")
    review["status"] = "VALIDATED" if action == "VALIDATE" else "REJECTED"
    review["action"] = action
    review["actor"] = actor
    review["notes"] = notes
    store.write_json(doc, "flywheel", f"queue-{run_id}.json")
    _audit(f"hitl_{action.lower()}", review_id, actor, notes)

    candidate = None
    if action == "VALIDATE":
        candidate = _create_candidate(run_id, review, actor)
        _append_suite_member(run_id, review, candidate)
    return {"review": review,
            "candidate": candidate.model_dump() if candidate else None}


def _create_candidate(run_id: str, review: Dict, actor: str) -> TrainingCandidate:
    cid = f"tc-{uuid.uuid4().hex[:10]}"
    cand = TrainingCandidate(
        candidate_id=cid, run_id=run_id,
        violation_id=review["violation_id"],
        cluster_id=review.get("cluster_id"),
        dataset_role="REGRESSION", training_eligible=False,
        guard_state="PROTECTED",
        provenance=Provenance(
            software_version=f"{SOFTWARE_VERSION}/{FLYWHEEL_VERSION}",
            source="SYNTHETIC"))
    doc = cand.model_dump()
    doc["studio2_entity_id"] = _mirror_to_studio2(cand)
    doc["registry_backend"] = REGISTRY_BACKEND
    store.write_json(doc, "flywheel", "candidates", f"{cid}.json")
    _audit("candidate_created", cid, actor,
           f"role=REGRESSION (protected) violation={review['violation_id']} "
           f"registry_backend={REGISTRY_BACKEND}")
    return cand


def _append_suite_member(run_id: str, review: Dict,
                         candidate: TrainingCandidate) -> None:
    suite = store.read_json("flywheel", "regression-suite.json") or {
        "suite_id": "rotr-regression-suite", "members": [],
        "registry_backend": REGISTRY_BACKEND}
    suite["members"].append({
        "candidate_id": candidate.candidate_id,
        "violation_id": review["violation_id"],
        "cluster_id": review.get("cluster_id"),
        "run_id": run_id, "role": "REGRESSION",
        "added_at": now_iso(), "added_by": review.get("actor"),
        "immutable_role_note": "REGRESSION members re-run against every "
                               "candidate; never training-eligible without "
                               "a recorded governance override"})
    store.write_json(suite, "flywheel", "regression-suite.json")


def get_suite() -> Dict:
    return store.read_json("flywheel", "regression-suite.json") or {
        "suite_id": "rotr-regression-suite", "members": [],
        "registry_backend": REGISTRY_BACKEND}


# ------------------------------------------------------------ contamination guard


def list_candidates() -> List[Dict]:
    out = []
    for name in store.list_dir("flywheel", "candidates"):
        doc = store.read_json("flywheel", "candidates", name)
        if doc:
            out.append(doc)
    return out


def get_candidate(candidate_id: str) -> Optional[Dict]:
    return store.read_json("flywheel", "candidates", f"{candidate_id}.json")


def promote_to_training(candidate_id: str, actor: str) -> Dict:
    """Refused for protected roles unless a governance override is recorded
    (raremine guard semantics)."""
    doc = get_candidate(candidate_id)
    if doc is None:
        raise KeyError(f"unknown candidate {candidate_id}")
    if doc["dataset_role"] in PROTECTED_ROLES and not doc.get("override"):
        _audit("promotion_refused", candidate_id, actor,
               f"protected role {doc['dataset_role']} without override")
        raise ContaminationError(
            f"candidate {candidate_id} holds protected role "
            f"{doc['dataset_role']}: training promotion refused without a "
            "recorded governance override")
    doc["training_eligible"] = True
    doc["guard_state"] = "OVERRIDDEN" if doc.get("override") else "ELIGIBLE"
    store.write_json(doc, "flywheel", "candidates", f"{candidate_id}.json")
    _audit("promoted_to_training", candidate_id, actor,
           f"guard_state={doc['guard_state']}")
    return doc


def governance_override(candidate_id: str, actor: str, reason: str) -> Dict:
    if not reason.strip():
        raise ValueError("a governance override requires a recorded reason")
    doc = get_candidate(candidate_id)
    if doc is None:
        raise KeyError(f"unknown candidate {candidate_id}")
    doc["override"] = {"actor": actor, "reason": reason, "at": now_iso()}
    doc["guard_state"] = "OVERRIDDEN"
    store.write_json(doc, "flywheel", "candidates", f"{candidate_id}.json")
    _audit("governance_override", candidate_id, actor, reason)
    return doc