File size: 9,055 Bytes
195f87e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d714735
195f87e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6ab97cc
195f87e
 
6ab97cc
195f87e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa68719
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195f87e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d714735
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195f87e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Qubit-Medic FastAPI server.

Built on **openenv-core** ``create_fastapi_app`` so the canonical OpenEnv
routes (``/reset``, ``/step``, ``/state``, ``/health``, ``/schema``,
``/metadata``, ``/mcp``) are wired automatically by the framework.

We add a few extras on top:

* ``GET  /healthz``   - the Day-0 deployment-substrate liveness probe
  (returns Stim/PyMatching/openenv versions). Used by the recurring
  4-hour HF Spaces wakeup ping.
* ``POST /decode``    - PyMatching baseline demo: takes a hand-crafted
  syndrome and returns the matching-decoder's prediction. Useful for
  the Gradio playground.

Run with ``python -m qubit_medic.server.app`` or
``uvicorn qubit_medic.server.app:app --host 0.0.0.0 --port 7860``.
"""
from __future__ import annotations

import logging
import os
import sys
from typing import Optional

from fastapi import Body, HTTPException
from fastapi.responses import HTMLResponse
from openenv.core import create_fastapi_app

from qubit_medic.config import DEFAULT_HOST, DEFAULT_PORT
from qubit_medic.server.environment import DecoderEnvironment
from qubit_medic.server.openenv_adapter import (
    QubitMedicAction,
    QubitMedicEnvironment,
    QubitMedicObservation,
)


logger = logging.getLogger("qubit_medic.server")
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))


# --------------------------------------------------------------------------- #
# Build the OpenEnv-compliant FastAPI app                                     #
# --------------------------------------------------------------------------- #

app = create_fastapi_app(
    env=QubitMedicEnvironment,
    action_cls=QubitMedicAction,
    observation_cls=QubitMedicObservation,
)
app.title = "Qubit-Medic OpenEnv"
app.version = os.getenv("QUBIT_MEDIC_VERSION", "1.0.0")
app.description = (
    "RL training environment for LLM-based quantum error-correction "
    "decoders. Built on Stim + PyMatching with five independent verifiable "
    "rewards (logical correction, syndrome consistency, Hamming overlap, "
    "format compliance, PyMatching beat-rate). Wraps "
    "qubit_medic.server.environment.DecoderEnvironment in "
    "openenv.core.Environment - see /metadata, /schema, /docs."
)


# --------------------------------------------------------------------------- #
# Day-0 + demo extras                                                          #
# --------------------------------------------------------------------------- #

# Lazy-built *legacy* DecoderEnvironment for /decode demos. The OpenEnv
# adapter has its own per-instance DecoderEnvironment; we keep this one
# around for the simple synchronous `/decode` baseline endpoint.
_legacy_env: Optional[DecoderEnvironment] = None


def _get_legacy_env() -> DecoderEnvironment:
    global _legacy_env
    if _legacy_env is None:
        _legacy_env = DecoderEnvironment()
        _legacy_env._cache_for("L1_warmup")  # noqa: SLF001
        _legacy_env._cache_for("L2_target")  # noqa: SLF001
    return _legacy_env


# --------------------------------------------------------------------------- #
# Compliance Section 2 (audit 2026-04): POST /state and POST /close.          #
# --------------------------------------------------------------------------- #
# OpenEnv's create_fastapi_app already mounts GET /state and (via the
# canonical contract) does not expose /close at all. The participant-guide
# audit explicitly requires POST /state and POST /close, so we surface
# both as additional routes that delegate to the legacy DecoderEnvironment
# singleton (the same one /decode already uses). The OpenEnv-canonical
# GET /state route is preserved untouched.
# --------------------------------------------------------------------------- #


@app.post("/state")
def post_state() -> dict:
    """POST mirror of the OpenEnv GET /state route.

    Returns a JSON-serialisable snapshot of env state. Uses the inner
    :meth:`DecoderEnvironment.state` (added in Section 1 compliance work)
    which excludes ground-truth fields by construction.
    """
    return _get_legacy_env().state()


@app.post("/close")
def post_close() -> dict:
    """POST /close: drop in-flight episodes on the legacy env singleton.

    The singleton is rebuilt lazily on the next /reset, so calling /close
    repeatedly is idempotent. Returns a small JSON dict so the caller can
    confirm the request landed.
    """
    _get_legacy_env().close()
    return {"ok": True, "closed": True}


@app.get("/healthz")
def healthz() -> dict:
    """Lightweight liveness probe (Day-0 deployment-substrate test).

    Returns the versions of Stim, PyMatching, and openenv so curl-ing
    this in a browser or from Colab proves both that networking works
    AND that the heavy quantum + RL deps actually loaded. Used by the
    recurring 4-hour HF Spaces wakeup ping.
    """
    import stim
    try:
        import pymatching as _pm
        pm_v = getattr(_pm, "__version__", "unknown")
    except Exception as exc:  # pragma: no cover - defensive
        pm_v = f"import-error: {exc}"
    try:
        import openenv as _oe
        oe_v = getattr(_oe, "__version__", "unknown")
    except Exception as exc:  # pragma: no cover - defensive
        oe_v = f"import-error: {exc}"
    return {
        "ok": True,
        "service": "qubit-medic",
        "version": app.version,
        "stim_version": stim.__version__,
        "pymatching_version": pm_v,
        "openenv_version": oe_v,
        "python_version": sys.version.split()[0],
    }


@app.post("/decode")
def decode(
    syndrome: list[int] = Body(..., embed=True),
    level: str = Body("L2_target", embed=True),
) -> dict:
    """Decode an arbitrary syndrome with PyMatching (baseline) and return
    its predicted Pauli frame and observable flip.

    Intended for the live Gradio demo: a notebook or web page can POST a
    hand-crafted syndrome here and visualise the matching-decoder result.
    """
    import numpy as np

    env = _get_legacy_env()
    cache = env._cache_for(level)  # noqa: SLF001
    arr = np.asarray(syndrome, dtype=np.uint8)
    if arr.shape[0] != cache.layout.num_detectors:
        raise HTTPException(
            status_code=400,
            detail=f"syndrome length {arr.shape[0]} != "
                   f"{cache.layout.num_detectors} expected for {level}",
        )
    from qubit_medic.server.physics import (
        predicted_observable_flip,
        pymatching_predicted_pauli_frame,
    )
    pm_obs = int(cache.matching.decode(arr)[0])
    px, pz = pymatching_predicted_pauli_frame(cache.matching, arr, cache.layout)
    return {
        "level": level,
        "syndrome": syndrome,
        "pymatching_observable_flip": pm_obs,
        "pymatching_x_errors": px,
        "pymatching_z_errors": pz,
        "implied_observable_from_x_errors": predicted_observable_flip(
            px, cache.layout
        ),
    }


# --------------------------------------------------------------------------- #
# Root landing page                                                            #
# --------------------------------------------------------------------------- #

@app.get("/", response_class=HTMLResponse, include_in_schema=False)
def root() -> HTMLResponse:
    """HTML landing page shown in the HF Spaces App tab."""
    html = """<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Qubit-Medic — OpenEnv server</title>
  <style>
    body { font-family: sans-serif; max-width: 680px; margin: 60px auto; color: #e0e0e0; background: #0d1117; }
    h1   { font-size: 1.5rem; }
    a    { color: #58a6ff; }
    code { background: #161b22; padding: 2px 6px; border-radius: 4px; font-size: 0.9em; }
    ul   { line-height: 2; }
  </style>
</head>
<body>
  <h1>Qubit-Medic &mdash; OpenEnv server</h1>
  <p>
    This Space exposes a <strong>JSON API</strong> for the quantum
    error-decoding environment (Stim + PyMatching, OpenEnv contract).
    Use the links below to interact with it.
  </p>
  <ul>
    <li><a href="/docs">Interactive API docs (Swagger)</a></li>
    <li><a href="/redoc">ReDoc</a></li>
    <li><a href="/healthz">Liveness <code>GET /healthz</code></a> &mdash; versions probe</li>
    <li><a href="/metadata">OpenEnv <code>GET /metadata</code></a></li>
  </ul>
  <p>
    Typical flow: <code>POST /reset</code> then <code>POST /step</code>
    with the model&rsquo;s text action &mdash; see the schema in <a href="/docs">/docs</a>.
  </p>
</body>
</html>"""
    return HTMLResponse(content=html)


# --------------------------------------------------------------------------- #
# Local entry point                                                            #
# --------------------------------------------------------------------------- #

def _main() -> None:
    import uvicorn

    uvicorn.run(
        "qubit_medic.server.app:app",
        host=os.getenv("QUBIT_MEDIC_HOST", DEFAULT_HOST),
        port=int(os.getenv("QUBIT_MEDIC_PORT", str(DEFAULT_PORT))),
        log_level=os.getenv("LOG_LEVEL", "info").lower(),
    )


if __name__ == "__main__":
    _main()