File size: 9,373 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
"""REST surface for the Studio 2.0 control plane under /api/studio2."""

from __future__ import annotations

import importlib
import os
from typing import Dict, Optional

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field

from sensorflow.studio2 import demo as demo_mod
from sensorflow.studio2 import funnel as funnel_mod
from sensorflow.studio2 import hardware as hw_mod
from sensorflow.studio2 import store
from sensorflow.studio2.registry import (
    DATASET_ROLES,
    KINDS,
    RoleTransitionError,
    get_registry,
    ingest_existing_stores,
)
from sensorflow.studio2.release_gate import ReleaseGate

router = APIRouter(prefix="/api/studio2", tags=["studio2"])

_SOFT_DEPS = ["sensorflow.agentic", "sensorflow.nextgen", "sensorflow.hardening",
              "sensorflow.retro"]
_HARD_DEPS = ["sensorflow.safety", "sensorflow.seqeval", "sensorflow.megaeval",
              "sensorflow.rca", "sensorflow.raremine", "sensorflow.bevfusion",
              "sensorflow.vitis", "sensorflow.evaluation"]


@router.get("/status")
def status():
    deps = {}
    for mod in _HARD_DEPS + _SOFT_DEPS:
        try:
            importlib.import_module(mod)
            deps[mod] = "importable"
        except Exception as e:
            deps[mod] = f"unavailable ({type(e).__name__})"
    return {"registry_counts": get_registry().counts(),
            "dependencies": deps,
            "soft_dependencies": _SOFT_DEPS}


# ------------------------------------------------------------------ registry


@router.get("/registry/summary")
def registry_summary():
    return {"counts": get_registry().counts(), "kinds": list(KINDS),
            "dataset_roles": list(DATASET_ROLES)}


@router.post("/registry/ingest")
def registry_ingest():
    return ingest_existing_stores(get_registry())


@router.get("/registry/{kind}")
def registry_list(kind: str):
    try:
        return {"kind": kind, "entities": get_registry().list(kind)}
    except KeyError as e:
        raise HTTPException(404, str(e))


@router.get("/registry/{kind}/{entity_id}")
def registry_get(kind: str, entity_id: str):
    if kind not in KINDS:
        raise HTTPException(404, f"unknown kind {kind}")
    doc = get_registry().get(kind, entity_id)
    if doc is None:
        raise HTTPException(404, f"no {kind} entity {entity_id}")
    return doc


class DatasetCreate(BaseModel):
    name: str
    role: str
    lineage_parents: list = Field(default_factory=list)
    meta: Dict = Field(default_factory=dict)
    actor: str = "api"


@router.post("/registry/datasets")
def dataset_create(req: DatasetCreate):
    try:
        return get_registry().register_dataset(
            req.name, req.role, lineage_parents=req.lineage_parents,
            meta=req.meta, actor=req.actor,
            provenance={"source_package": "studio2-api"})
    except ValueError as e:
        raise HTTPException(400, str(e))


class RoleTransition(BaseModel):
    new_role: str
    actor: str
    override_reason: Optional[str] = None


@router.post("/registry/datasets/{dataset_id}/role")
def dataset_role(dataset_id: str, req: RoleTransition):
    try:
        return get_registry().transition_role(
            dataset_id, req.new_role, req.actor,
            override_reason=req.override_reason)
    except RoleTransitionError as e:
        raise HTTPException(409, str(e))
    except KeyError as e:
        raise HTTPException(404, str(e))
    except ValueError as e:
        raise HTTPException(400, str(e))


@router.get("/audit")
def audit_log(limit: int = 100):
    return {"events": store.read_audit(limit=limit)}


# ------------------------------------------------------------------ release


class ReleaseEvaluate(BaseModel):
    """Inputs may be omitted; the gate degrades to REVIEW naming the gap.
    When use_live_sources=true the server gathers the real latest inputs."""
    safety_metrics: Optional[Dict] = None
    regression_results: Optional[Dict] = None
    distribution_shift: Optional[Dict] = None
    agentic_outcome: Optional[Dict] = None
    closed_loop: Optional[Dict] = None
    hardware_matrix: Optional[Dict] = None
    policy: Optional[Dict] = None
    context: Optional[Dict] = None
    use_live_sources: bool = False


def _gather_live_inputs(req: ReleaseEvaluate) -> ReleaseEvaluate:
    data = req.model_dump()
    try:
        from sensorflow.megaeval.runs import get_mega_store
        from sensorflow.megaeval import analysis as mega_analysis
        from sensorflow.safety import gates as safety_gates
        mega = get_mega_store()
        published = sorted(
            [r for r in mega.runs.values() if getattr(r, "status", "") == "published"],
            key=lambda r: getattr(r, "published_at", "") or "")
        if len(published) >= 2 and data["safety_metrics"] is None:
            cand = published[-1]
            data["safety_metrics"] = (safety_gates.latest_gate_result(cand.run_id)
                                      or safety_gates.evaluate_gates(
                                          mega, cand, published[-2]))
        if published and data["distribution_shift"] is None:
            data["distribution_shift"] = mega_analysis.distribution_shift(
                mega, published[-1])
    except Exception:
        pass
    if data["regression_results"] is None:
        try:
            from sensorflow.seqeval.controller import get_seqeval_store
            states = get_seqeval_store().list_states()
            done = [s for s in states if s.get("status") == "done"]
            if done:
                s = done[0]
                data["regression_results"] = {
                    "run_id": s["run_id"], "decision": s["decision"],
                    "stopping_reason": s.get("stopping_reason"),
                    "samples_used": s.get("budget", {}).get("samples_used"),
                    "affected_strata": [], "regression_map": []}
        except Exception:
            pass
    if data["hardware_matrix"] is None:
        try:
            data["hardware_matrix"] = (hw_mod.latest_matrix()
                                       or hw_mod.gate_matrix())
        except Exception:
            pass
    return ReleaseEvaluate(**data)


@router.post("/release/evaluate")
def release_evaluate(req: ReleaseEvaluate):
    if req.use_live_sources:
        req = _gather_live_inputs(req)
    gate = ReleaseGate(get_registry(), policy=req.policy)
    return gate.evaluate(
        safety_metrics=req.safety_metrics,
        regression_results=req.regression_results,
        distribution_shift=req.distribution_shift,
        agentic_outcome=req.agentic_outcome,
        closed_loop=req.closed_loop,
        hardware_matrix=req.hardware_matrix,
        context=req.context)


@router.get("/release/decisions")
def release_decisions():
    return {"decisions": get_registry().list("decisions")}


@router.get("/release/decisions/{decision_id}")
def release_decision(decision_id: str):
    doc = get_registry().get("decisions", decision_id)
    if doc is None:
        raise HTTPException(404, f"no decision {decision_id}")
    return doc


class Approval(BaseModel):
    approver: str
    rationale: str


@router.post("/release/decisions/{decision_id}/approve")
def release_approve(decision_id: str, req: Approval):
    gate = ReleaseGate(get_registry())
    try:
        return gate.approve(decision_id, req.approver, req.rationale)
    except KeyError as e:
        raise HTTPException(404, str(e))
    except ValueError as e:
        raise HTTPException(409, str(e))


# ------------------------------------------------------------------ hardware


@router.get("/hardware/matrix")
def hardware_matrix(refresh: bool = False):
    if not refresh:
        cached = hw_mod.latest_matrix()
        if cached:
            return cached
    try:
        return hw_mod.gate_matrix()
    except Exception as e:
        raise HTTPException(503, f"hardware matrix unavailable: {e}")


# ------------------------------------------------------------------ funnel


@router.get("/funnel")
def funnel():
    return funnel_mod.build_funnel(registry=get_registry())


# ------------------------------------------------------------------ demo


class DemoRequest(BaseModel):
    seed: int = demo_mod.DEMO_SEED


@router.post("/demo/run")
def demo_run(req: DemoRequest):
    return demo_mod.run_demo(seed=req.seed, registry=get_registry())


@router.get("/demo/latest")
def demo_latest():
    doc = demo_mod.latest_demo()
    if doc is None:
        raise HTTPException(404, "no demo run yet; POST /api/studio2/demo/run")
    return doc


# ------------------------------------------------------------------ docs


_DOCS_DIR = os.path.join("docs", "architecture")


@router.get("/docs")
def docs_list():
    if not os.path.isdir(_DOCS_DIR):
        return {"docs": []}
    return {"docs": [n for n in sorted(os.listdir(_DOCS_DIR))
                     if n.startswith("studio2-") and n.endswith(".md")]}


@router.get("/docs/{name}")
def docs_get(name: str):
    if not (name.startswith("studio2-") and name.endswith(".md")
            and "/" not in name and ".." not in name):
        raise HTTPException(404, "unknown document")
    path = os.path.join(_DOCS_DIR, name)
    if not os.path.exists(path):
        raise HTTPException(404, f"no document {name}")
    with open(path) as f:
        return {"name": name, "content": f.read()}