File size: 16,672 Bytes
6342806
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
"""Deterministic release gate: composes the real verdicts into one decision.

ReleaseGate.evaluate(safety_metrics, regression_results, distribution_shift,
policy) -> ReleaseDecision, where the inputs are the actual outputs of the
existing engines:

    safety_metrics       sensorflow.safety.gates.evaluate_gates(...) result
                         (decision RELEASE_READY/BLOCKED + layered gates)
    regression_results   sensorflow.seqeval.evaluate_regression(...) verdict
                         (decision PASS/REGRESSION/INCONCLUSIVE + regression map)
    distribution_shift   sensorflow.megaeval.analysis.distribution_shift(...)
                         report ({"shifts": [...]})
    agentic_outcome      sensorflow.agentic.policy.evaluate(...) result (optional)
    closed_loop          sensorflow.nextgen gauntlet result or causal-replay
                         verdict (optional)
    hardware_matrix      sensorflow.studio2.hardware.evaluate_matrix(...) report
                         (optional)

Hard rules (all covered by tests):
  - GO never authorizes deployment. A ReleaseDecision with status GO still has
    deployment_authorized=False; authorization is a separate recorded human
    approval action (Registry 'approvals' + audit).
  - A missing or failed required subsystem degrades the decision to REVIEW
    with the gap named — never a silent GO.
  - The policy is content-hash versioned, and every decision records the full
    evidence tuple it was computed from.
"""

from __future__ import annotations

from typing import Dict, List, Optional

from sensorflow.studio2 import store
from sensorflow.studio2.registry import Registry, get_registry, new_id

GO, REVIEW, NO_GO = "GO", "REVIEW", "NO_GO"

DEFAULT_RELEASE_POLICY: Dict = {
    "policy_name": "studio2-release-v1",
    # subsystems that MUST contribute evidence; absence -> REVIEW, named
    "required_inputs": ["safety_gates", "sequential_regression",
                        "distribution_shift"],
    # consulted when supplied; never silently skipped once configured here
    "optional_inputs": ["agentic_policy", "closed_loop", "hardware_matrix"],
    # a cohort whose eval share shifted AND whose recall trails overall by
    # more than this gap forces REVIEW (megaeval shift report semantics)
    "shift_review_recall_gap": -0.05,
    "inconclusive_outcome": REVIEW,          # never GO on absence of evidence
    "critical_hardware_failure_outcome": NO_GO,
    "confidence_penalty_per_gap": 0.25,
    "confidence_penalty_per_review_condition": 0.10,
}


class ReleaseGate:
    def __init__(self, registry: Optional[Registry] = None,
                 policy: Optional[Dict] = None):
        self.registry = registry or get_registry()
        doc = {**DEFAULT_RELEASE_POLICY, **(policy or {})}
        self.policy_entity = self.registry.register_policy(
            name=doc.get("policy_name", "studio2-release"), doc=doc,
            provenance={"source_package": "studio2"})
        self.policy = doc

    # ---------------------------------------------------------------- signals

    def _signal_safety(self, payload: Optional[Dict]) -> Dict:
        if not payload or "decision" not in payload:
            return {"present": False, "gap": "safety gate results unavailable "
                    "(sensorflow.safety evaluate_gates output missing)"}
        blocking = payload.get("blocking_gates") or []
        if payload["decision"] == "RELEASE_READY" and not blocking:
            return {"present": True, "verdict": "pass",
                    "evidence": {"safety_gate_run": payload.get("candidate_run_id"),
                                 "evidence_package_id": payload.get("evidence_package_id")}}
        return {"present": True, "verdict": "block",
                "conditions": [f"safety gate blocked: {g}" for g in blocking]
                or ["safety layer decision BLOCKED"],
                "evidence": {"safety_gate_run": payload.get("candidate_run_id"),
                             "evidence_package_id": payload.get("evidence_package_id")}}

    def _signal_seqeval(self, payload: Optional[Dict]) -> Dict:
        if not payload or "decision" not in payload:
            return {"present": False, "gap": "sequential regression verdict "
                    "unavailable (sensorflow.seqeval evaluate_regression missing)"}
        decision = payload["decision"]
        evidence = {"seqeval_run_id": payload.get("run_id"),
                    "samples_used": payload.get("samples_used")}
        if decision == "PASS":
            return {"present": True, "verdict": "pass", "evidence": evidence}
        if decision == "REGRESSION":
            strata = payload.get("affected_strata") or []
            return {"present": True, "verdict": "block",
                    "conditions": [
                        "sequential regression confirmed"
                        + (f" in strata: {', '.join(map(str, strata))}" if strata else "")],
                    "evidence": evidence}
        # INCONCLUSIVE / INSUFFICIENT: absence of evidence is never a pass
        reason = payload.get("stopping_reason") or "budget exhausted without a decision"
        return {"present": True, "verdict": "review",
                "questions": [f"sequential regression verdict {decision}: {reason}"],
                "evidence": evidence}

    def _signal_shift(self, payload: Optional[Dict]) -> Dict:
        if not payload or "shifts" not in payload:
            return {"present": False, "gap": "distribution shift report "
                    "unavailable (megaeval shift monitor missing)"}
        gap_limit = self.policy["shift_review_recall_gap"]
        flagged = [s for s in payload["shifts"]
                   if s.get("recall_gap") is not None and s["recall_gap"] <= gap_limit]
        evidence = {"shift_run_id": payload.get("run_id"),
                    "shifts_reported": len(payload["shifts"])}
        if flagged:
            return {"present": True, "verdict": "review",
                    "questions": [
                        f"distribution shift with recall impact on {s['cohort']}: "
                        f"eval share {s.get('relative_change', 0)*100:+.0f}% vs train, "
                        f"cohort recall gap {s['recall_gap']:+.3f} — open an RCA "
                        "investigation (offline-vs-shadow 4-way root cause)"
                        for s in flagged[:5]],
                    "evidence": evidence}
        return {"present": True, "verdict": "pass", "evidence": evidence}

    def _signal_agentic(self, payload: Optional[Dict]) -> Dict:
        if not payload or "outcome" not in payload:
            return {"present": False, "gap": "agentic policy outcome unavailable "
                    "(sensorflow.agentic not importable or not evaluated)"}
        outcome = payload["outcome"]
        evidence = {"agentic_policy_version": payload.get("policy_version"),
                    "failure_id": payload.get("failure_id"),
                    "outcome": outcome}
        if outcome == "AUTOMATIC_STOP_SHIP":
            return {"present": True, "verdict": "block",
                    "conditions": ["agentic policy fired AUTOMATIC_STOP_SHIP "
                                   f"(severity {payload.get('severity')})"],
                    "evidence": evidence}
        if outcome == "INDETERMINATE":
            return {"present": True, "verdict": "review",
                    "questions": ["agentic policy INDETERMINATE (fail-safe): "
                                  + "; ".join(payload.get("indeterminate_reasons") or
                                              ["integrity failure"])],
                    "evidence": evidence}
        return {"present": True, "verdict": "pass", "evidence": evidence}

    def _signal_closed_loop(self, payload: Optional[Dict]) -> Dict:
        if not payload:
            return {"present": False, "gap": "closed-loop verdict unavailable "
                    "(sensorflow.nextgen not importable or no replay/gauntlet run)"}
        # accept either a gauntlet result or a causal replay result
        evidence = {"closed_loop_run_id": payload.get("run_id")
                    or payload.get("scenario_id")}
        if payload.get("halted") or payload.get("recommendation") == "NO_GO":
            return {"present": True, "verdict": "block",
                    "conditions": ["closed-loop gauntlet halted: "
                                   + str(payload.get("halt_reason")
                                         or payload.get("recommendation"))],
                    "evidence": evidence}
        if payload.get("verdict") == "BEHAVIORALLY_CONSEQUENTIAL":
            return {"present": True, "verdict": "review",
                    "questions": ["closed-loop replay shows behaviorally "
                                  "consequential perception divergence on "
                                  f"{payload.get('scenario_id')}"],
                    "evidence": evidence}
        return {"present": True, "verdict": "pass", "evidence": evidence}

    def _signal_hardware(self, payload: Optional[Dict]) -> Dict:
        if not payload or "status" not in payload:
            return {"present": False, "gap": "hardware gate matrix unavailable"}
        evidence = {"hardware_matrix_id": payload.get("matrix_id"),
                    "combinations": payload.get("n_combinations")}
        questions = [
            f"insufficient evidence for {c['combination_label']}: {c['reason']}"
            for c in (payload.get("insufficient") or [])[:5]]
        if payload["status"] == "FAIL_CRITICAL":
            outcome = self.policy["critical_hardware_failure_outcome"]
            return {"present": True,
                    "verdict": "block" if outcome == NO_GO else "review",
                    "conditions": [
                        f"critical hardware combination failing: {c['combination_label']}"
                        f" ({', '.join(c['failed_checks'])})"
                        for c in (payload.get("critical_failures") or [])[:5]],
                    "questions": questions, "evidence": evidence}
        if payload["status"] == "FAIL":
            return {"present": True, "verdict": "review",
                    "questions": questions + [
                        f"non-critical hardware combination failing: {c['combination_label']}"
                        for c in (payload.get("failures") or [])[:5]],
                    "evidence": evidence}
        return {"present": True,
                "verdict": "review" if questions else "pass",
                "questions": questions, "evidence": evidence}

    # ---------------------------------------------------------------- evaluate

    def evaluate(self, safety_metrics: Optional[Dict],
                 regression_results: Optional[Dict],
                 distribution_shift: Optional[Dict],
                 policy: Optional[Dict] = None,
                 agentic_outcome: Optional[Dict] = None,
                 closed_loop: Optional[Dict] = None,
                 hardware_matrix: Optional[Dict] = None,
                 context: Optional[Dict] = None,
                 actor: str = "studio2") -> Dict:
        """Pure function of inputs + versioned policy; persists and returns a
        ReleaseDecision."""
        if policy:
            gate = ReleaseGate(self.registry, policy={**self.policy, **policy})
            return gate.evaluate(safety_metrics, regression_results,
                                 distribution_shift, agentic_outcome=agentic_outcome,
                                 closed_loop=closed_loop,
                                 hardware_matrix=hardware_matrix,
                                 context=context, actor=actor)

        signals = {
            "safety_gates": self._signal_safety(safety_metrics),
            "sequential_regression": self._signal_seqeval(regression_results),
            "distribution_shift": self._signal_shift(distribution_shift),
            "agentic_policy": self._signal_agentic(agentic_outcome),
            "closed_loop": self._signal_closed_loop(closed_loop),
            "hardware_matrix": self._signal_hardware(hardware_matrix),
        }

        required = self.policy["required_inputs"]
        optional = self.policy["optional_inputs"]
        considered = list(required) + list(optional)

        blocking: List[str] = []
        questions: List[str] = []
        gaps: List[str] = []
        evidence_tuple: Dict = {"release_policy_version":
                                self.policy_entity["policy_version"]}
        for name in considered:
            sig = signals[name]
            if not sig["present"]:
                if name in required:
                    gaps.append(sig["gap"])
                else:
                    # optional-but-configured subsystems: absence is surfaced
                    # as an open question, never silently ignored
                    questions.append(f"[{name}] {sig['gap']}")
                evidence_tuple[name] = None
                continue
            evidence_tuple[name] = sig.get("evidence")
            blocking.extend(sig.get("conditions") or [])
            questions.extend(sig.get("questions") or [])
        if context:
            evidence_tuple["context"] = context

        if blocking:
            status = NO_GO
        elif gaps or questions:
            status = REVIEW
        else:
            status = GO

        n_present = sum(1 for n in considered if signals[n]["present"])
        evidence_completeness = round(n_present / len(considered), 3)
        confidence = 1.0
        confidence -= self.policy["confidence_penalty_per_gap"] * len(gaps)
        confidence -= (self.policy["confidence_penalty_per_review_condition"]
                       * len(questions))
        confidence = round(max(0.05, min(confidence, evidence_completeness
                                         if status != NO_GO else 1.0)), 3)

        decision = {
            "entity_id": new_id("rd"),
            "status": status,
            "confidence": confidence,
            "evidence_completeness": evidence_completeness,
            "blocking_conditions": blocking,
            "unresolved_questions": questions,
            "degraded_inputs": gaps,
            # GO is a *recommendation*; deployment always needs a human
            "human_approval_required": True,
            "deployment_authorized": False,
            "approval": None,
            "policy_version": self.policy_entity["policy_version"],
            "evidence_tuple": evidence_tuple,
            "signals": {k: {kk: vv for kk, vv in v.items() if kk != "evidence"}
                        for k, v in signals.items()},
            "evaluated_at": store.now_iso(),
        }
        self.registry.put("decisions", decision)
        store.audit("release_decision", "decisions", decision["entity_id"],
                    actor=actor, detail=f"status={status} "
                    f"completeness={evidence_completeness}",
                    payload={"blocking": blocking[:5], "gaps": gaps[:5]})
        return decision

    # ---------------------------------------------------------------- approval

    def approve(self, decision_id: str, approver: str, rationale: str) -> Dict:
        """The separate human action that authorizes deployment. Only a GO
        decision can be approved; REVIEW/NO_GO must be re-evaluated first."""
        if not approver.strip() or not rationale.strip():
            raise ValueError("human approval requires a named approver and a rationale")
        decision = self.registry.get("decisions", decision_id)
        if decision is None:
            raise KeyError(f"unknown release decision {decision_id}")
        if decision["status"] != GO:
            raise ValueError(
                f"decision {decision_id} has status {decision['status']}; only GO "
                "decisions can be approved for deployment — resolve the blocking "
                "conditions / unresolved questions and re-evaluate")
        approval = {
            "entity_id": new_id("app"),
            "decision_id": decision_id,
            "approver": approver,
            "rationale": rationale,
            "approved_at": store.now_iso(),
        }
        self.registry.put("approvals", approval)
        decision["deployment_authorized"] = True
        decision["approval"] = approval
        self.registry.put("decisions", decision)
        store.audit("human_approval", "approvals", approval["entity_id"],
                    actor=approver, detail=f"authorized deployment for {decision_id}",
                    payload={"rationale": rationale})
        return decision