atakan Claude Opus 5 commited on
Commit
c5194c9
·
1 Parent(s): 74544ce

fix: Make the Space a Gradio app, which is what ZeroGPU requires

Browse files

ZeroGPU assumes the Gradio app *is* the Space: it schedules GPU workers by
forking the server process, and its startup validation looks for a @spaces.GPU
function wired to a Gradio event handler. Keeping FastAPI on the public port
with Gradio as a hidden side-car was the seventh and last incompatibility -- the
GPU was scheduled and acquired and the forked worker still died in torch.init(),
while the platform probed the public port for /api/predict and got 404. A no-op
@spaces.GPU function failed the same way, so it was never the model.

So Gradio owns the port and ControlAgent runs inside @spaces.GPU. The web/
console is lost on the Space only; the agent, the 29 solvers, the verifier and
the 80,370-chunk retriever are unchanged, and app.py keeps the console locally.

The turn is the unit wrapped by @spaces.GPU, not each generation: a turn is
several generations sharing one KV cache. Tool activity is streamed as it
happens so a multi-step turn does not look like a hang, and plots and sources
are appended when it finishes.

The other six constraints are all still respected -- import-scope build, no
device_map, agent via module global, embedder warmed in the same window, no
upper bounds in requirements, and no pinning torch below CUDA 12.8 (ZeroGPU is
Blackwell, sm_120). All seven are written up in CLAUDE.md.

engine_api.py stays, unused: it works and only needs a token permission, and it
is the fallback for a demo with no GPU at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Files changed (3) hide show
  1. CLAUDE.md +38 -44
  2. app_space.py +124 -34
  3. requirements-space.txt +15 -22
CLAUDE.md CHANGED
@@ -179,53 +179,47 @@ formats correctly. `CONTROLAI_ADAPTER=<path>` loads one anyway for A/B work.
179
  discovery → extraction → dataset generation → training → evaluation). That pipeline is independent
180
  of the serving path and uses `requirements-training.txt`/`requirements-corpus.txt`.
181
 
182
- ### Deployment (`app_space.py`, `engine_api.py`, `requirements-space.txt`)
183
- The demo Space runs **plain CPU hardware with hosted generation**, not ZeroGPU.
184
- `CONTROLAI_BACKEND=api` makes `app.py::_make_engine` build `RemoteEngine`; everything else
185
- all 29 solvers, the verifier, the full 80,370-chunk retriever runs in-process on the Space's CPU,
186
- where it costs milliseconds. Only token generation leaves the machine. The Space page says so
187
- plainly, because the local app's whole point is that nothing does.
188
-
189
- `engine_api.py` renders the tool schemas through the model's own chat template locally and hands
190
- the result over as an ordinary **system message** — never as a `tools=` argument, which would make
191
- the provider apply its own template and return structured `tool_calls` the agent does not speak.
192
- Every provider serving Qwen3 offers `conversational` only, not raw text-generation, so `render()`
193
- returns a *message list* rather than a string; the agent treats that value as opaque, so nothing
194
- downstream cares. Thinking is disabled with Qwen3's `/no_think` soft switch rather than
195
- `chat_template_kwargs`, which is an `extra_body` passthrough not every provider forwards.
196
-
197
- `HF_TOKEN` must be a Space secret with **two** permissions: read on the private index dataset repo,
198
- and *Make calls to Inference Providers*. Missing the first disables retrieval silently; missing the
199
- second fails generation with `403 ... does not have sufficient permissions to call Inference
200
- Providers`.
201
-
202
- **ZeroGPU was tried at length and abandoned.** `engine_torch.py` is kept and works on an ordinary
203
- CUDA box; it is ZeroGPU specifically that does not fit. Seven distinct problems, recorded so the
204
- attempt is not repeated blind:
205
- 1. **Dependency resolution.** The platform appends its own `gradio[oauth,mcp]`, `spaces`, `uvicorn`
206
- and a `torch` ceiling to `requirements.txt`. Any upper bound of your own can make the resolve
207
- impossible — `transformers<4.56` did, because gradio 6 needs `huggingface-hub>=1.16` and every
208
- `transformers<4.56` needs `<1.0`.
209
- 2. **Import-window loading.** ZeroGPU patches torch during the entry module's import and only
210
- intercepts CUDA inside that window. Building the model in FastAPI's `lifespan`, on a worker
211
- thread, reaches real CUDA init and raises.
212
- 3. **`device_map` and bitsandbytes.** `device_map` routes transformers through
213
  `caching_allocator_warmup`'s direct `torch.empty(..., device="cuda")`, which trips the same
214
- guard; bitsandbytes requires `device_map`, so 4-bit is unavailable.
215
  4. **Inference must be inside `@spaces.GPU`.** Otherwise the packed tensors are never materialised
216
  and forward passes return fluent multilingual noise at ~15 min/turn — up, responsive, and
217
- confidently wrong.
218
- 5. **Thread context.** `spaces` intercepts CUDA only in the context it manages; a call from a
219
- custom `ThreadPoolExecutor` thread fails with `No CUDA GPUs are available` even with a GPU
220
- attached. Found and fixed once before in 4de16e3, then reintroduced by the MLX rewrite.
221
- 6. **The probe must be `launch()`ed, not mounted.** `gr.mount_gradio_app` fails startup with
222
- "No @spaces.GPU function detected". Launch it on its own port with `ssr_mode=False` (Gradio 6's
223
- SSR spawns a Node subprocess into the process ZeroGPU forks from).
224
- 7. **The structural one.** With all six fixed, the GPU is scheduled and acquired and ZeroGPU's own
225
- forked worker still dies in `torch.init()`. A no-op `@spaces.GPU` function fails identically, so
226
- it is not the model or the payload. Meanwhile the platform probes the public port for Gradio's
227
- `/api/predict` and gets 404, because FastAPI owns it. ZeroGPU assumes the Gradio app *is* the
228
- Space; this one is not.
 
 
 
 
 
 
229
 
230
  ### Benchmark (`benchmarks/`)
231
  `controlbench_v1.jsonl` is the eval set; `SCOPE.md` defines the taxonomy and `README.md` the
 
179
  discovery → extraction → dataset generation → training → evaluation). That pipeline is independent
180
  of the serving path and uses `requirements-training.txt`/`requirements-corpus.txt`.
181
 
182
+ ### Deployment (`app_space.py`, `engine_torch.py`, `requirements-space.txt`)
183
+ The demo Space is a **Gradio app on ZeroGPU**, not the FastAPI console. That is not a preference:
184
+ **ZeroGPU assumes the Gradio app is the Space.** It schedules GPU workers by forking the server
185
+ process, and its startup validation looks for a `@spaces.GPU` function wired to a Gradio event
186
+ handler. Keeping FastAPI on the public port with Gradio as a hidden side-car was tried at length and
187
+ does not work — see the list below. `app_space.py` therefore gives Gradio the port and drives
188
+ `ControlAgent` from inside `@spaces.GPU`. The `web/` console is lost on the Space only; the agent,
189
+ the 29 solvers, the verifier and the 80,370-chunk retriever are all the same.
190
+
191
+ Seven ZeroGPU constraints, each found the hard way. Read these before changing anything here:
192
+ 1. **No upper bounds in `requirements-space.txt`.** The platform appends its own
193
+ `gradio[oauth,mcp]`, `spaces`, `uvicorn` and a `torch` ceiling; one extra constraint can make the
194
+ resolve impossible. `transformers<4.56` did, because gradio 6 needs `huggingface-hub>=1.16` and
195
+ every `transformers<4.56` needs `<1.0`. **And do not pin torch down to CUDA 12** — ZeroGPU is
196
+ backed by Blackwell (sm_120), which needs CUDA 12.8+; an older wheel has no kernels for it.
197
+ 2. **Build the model at import scope.** ZeroGPU patches torch during the entry module's import and
198
+ only intercepts CUDA inside that window. Building it in a lifespan or a request reaches real CUDA
199
+ init and raises `Low-level CUDA init (torch._C._cuda_init) reached`.
200
+ 3. **No `device_map`, no bitsandbytes.** `device_map` routes transformers through
 
 
 
 
 
 
 
 
 
 
 
 
201
  `caching_allocator_warmup`'s direct `torch.empty(..., device="cuda")`, which trips the same
202
+ guard; bitsandbytes requires `device_map`, so 4-bit is unavailable and the model must fit in bf16.
203
  4. **Inference must be inside `@spaces.GPU`.** Otherwise the packed tensors are never materialised
204
  and forward passes return fluent multilingual noise at ~15 min/turn — up, responsive, and
205
+ confidently wrong. Wrap the whole *turn*: a turn is several generations sharing one KV cache.
206
+ 5. **Never pass the agent as an argument** to a `@spaces.GPU` function; reach it through a module
207
+ global. ZeroGPU marshals arguments across a process boundary and tries to share CUDA tensors,
208
+ hanging with no output.
209
+ 6. **The embedder is a second model** with the same import-window problem. It loads lazily on the
210
+ first query, so `_build()` embeds one throwaway string to force it in. Without that the Space
211
+ starts fine and every answer carries `[agent] retrieval failed`.
212
+ 7. **Gradio must own the public port.** With all of the above fixed but FastAPI on the port, the GPU
213
+ was scheduled and acquired and ZeroGPU's own forked worker still died in `torch.init()`, while
214
+ the platform probed the public port for `/api/predict` and got 404. A no-op `@spaces.GPU`
215
+ function failed identically, so it was not the model or the payload.
216
+
217
+ `HF_TOKEN` must be a Space secret with read access to the private index dataset repo, or retrieval
218
+ is silently disabled.
219
+
220
+ `engine_api.py` is an unused-but-working alternative: `CONTROLAI_BACKEND=api` runs generation over
221
+ Inference Providers with everything else local. It needs a token carrying *Make calls to Inference
222
+ Providers*. Kept for anyone who wants a demo without a GPU at all.
223
 
224
  ### Benchmark (`benchmarks/`)
225
  `controlbench_v1.jsonl` is the eval set; `SCOPE.md` defines the taxonomy and `README.md` the
app_space.py CHANGED
@@ -1,43 +1,50 @@
1
- """Hugging Face Space entry point: FastAPI, CPU hardware, hosted generation.
2
-
3
- `./run.sh` runs `app.py` directly; this exists only for the Space.
4
-
5
- **This deliberately does not use ZeroGPU.** Seven separate incompatibilities came
6
- out of trying to, and the last one is structural rather than a bug: ZeroGPU is
7
- built around "the Gradio app *is* the Space", scheduling GPU workers by forking
8
- the Gradio server process, while ControlAI is a FastAPI app with its own console
9
- that merely borrows the process. The findings are catalogued in CLAUDE.md so the
10
- attempt is not repeated blind, and `engine_torch.py` is kept for anyone running
11
- this on an ordinary CUDA box, where all of it works.
12
-
13
- What runs where: everything that makes ControlAI what it is stays in-process on
14
- the Space's CPU -- all 29 deterministic solvers, the verifier, the full
15
- 80,370-chunk hybrid retriever. Those cost milliseconds. Only token generation
16
- leaves, to a GPU behind an API, via `RemoteEngine`.
17
-
18
- The Space needs `HF_TOKEN` as a secret, with **two** permissions: read access to
19
- the private dataset repo holding the index, and "Make calls to Inference
20
- Providers". Without the first, retrieval is silently disabled; without the
21
- second, generation fails with 403.
 
 
 
 
 
 
22
  """
23
 
24
  from __future__ import annotations
25
 
26
  import os
 
 
27
 
28
- # Must precede the `app` import: it decides which engine gets built. Assigned,
29
- # not setdefault-ed, because a stale Space variable must not select something
30
- # else -- CONTROLAI_BACKEND=pytorch, left from the old orchestrator, once did.
31
- os.environ["CONTROLAI_BACKEND"] = "api"
32
 
33
- import uvicorn
 
34
 
35
- import app as app_module
36
- from app import app
 
37
 
38
 
39
  def _fetch_index() -> None:
40
- """Pull the retrieval index in before the agent starts."""
41
  from controlai_rag.fetch_index import fetch
42
 
43
  if not os.environ.get("HF_TOKEN"):
@@ -50,11 +57,94 @@ def _fetch_index() -> None:
50
  print(f"[space] could not fetch the index ({exc}); retrieval disabled")
51
 
52
 
53
- def main() -> None:
54
- _fetch_index()
55
- uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 7860)),
56
- log_level="info")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
  if __name__ == "__main__":
60
- main()
 
 
1
+ """Hugging Face Space entry point: a Gradio app on ZeroGPU.
2
+
3
+ `./run.sh` runs `app.py` -- FastAPI plus the `web/` console -- and that is the
4
+ real product. This file is a *different front end over the same agent*, and it
5
+ exists because of one hard platform constraint:
6
+
7
+ **ZeroGPU assumes the Gradio app is the Space.** It schedules GPU workers by
8
+ forking the server process, and its startup validation looks for a `@spaces.GPU`
9
+ function wired to a Gradio event handler. An earlier version of this file kept
10
+ FastAPI on the public port and ran Gradio as a hidden side-car; the GPU was
11
+ scheduled and acquired and the forked worker still died in `torch.init()`, while
12
+ the platform probed the public port for `/api/predict` and got 404. Six other
13
+ incompatibilities were fixed before that one; all seven are in CLAUDE.md.
14
+
15
+ So here Gradio owns the port and the agent runs inside `@spaces.GPU`. What is
16
+ lost is the `web/` console, on the Space only. What is kept is everything that
17
+ matters: the same `ControlAgent`, the same 29 deterministic solvers, the same
18
+ verifier, the same 80,370-chunk hybrid retriever, the same model.
19
+
20
+ Two ordering rules, both learned the hard way:
21
+ * The model is built at **import scope**. ZeroGPU patches torch during the
22
+ entry module's import and only intercepts CUDA inside that window; building
23
+ it later reaches real CUDA init and raises.
24
+ * The agent is reached from inside the GPU function through a **module
25
+ global**, never passed as an argument. ZeroGPU marshals arguments across a
26
+ process boundary and would try to share the model's CUDA tensors, hanging
27
+ with no output.
28
  """
29
 
30
  from __future__ import annotations
31
 
32
  import os
33
+ from pathlib import Path
34
+ from typing import Iterator
35
 
36
+ # Must precede the `controlai_agent` imports below.
37
+ os.environ["CONTROLAI_BACKEND"] = "torch"
 
 
38
 
39
+ import gradio as gr
40
+ import spaces
41
 
42
+ PLOTS_DIR = Path("outputs/plots")
43
+
44
+ AGENT = None
45
 
46
 
47
  def _fetch_index() -> None:
 
48
  from controlai_rag.fetch_index import fetch
49
 
50
  if not os.environ.get("HF_TOKEN"):
 
57
  print(f"[space] could not fetch the index ({exc}); retrieval disabled")
58
 
59
 
60
+ def _build() -> None:
61
+ """Build the agent while ZeroGPU is still watching for CUDA calls."""
62
+ global AGENT
63
+ from controlai_agent.agent import ControlAgent
64
+ from controlai_agent.engine_torch import TorchEngine
65
+ from controlai_rag.embeddings import get_embedder
66
+
67
+ print("[space] building agent at import scope (ZeroGPU CUDA window)")
68
+ AGENT = ControlAgent(engine=TorchEngine())
69
+ # The retrieval embedder is a second model and loads lazily on first query --
70
+ # a request, outside the window. Embedding one string forces it in here too.
71
+ get_embedder().encode_query("warmup")
72
+ print("[space] agent and embedder ready")
73
+
74
+
75
+ @spaces.GPU(duration=300)
76
+ def respond(message: str, history: list) -> Iterator[str]:
77
+ """One agent turn, streamed. Runs with real hardware attached.
78
+
79
+ Wraps the whole turn rather than each generation: a turn is several
80
+ generations sharing one KV cache, and splitting them across separate
81
+ @spaces.GPU calls would put that shared state across a process boundary on
82
+ every tool step.
83
+ """
84
+ if AGENT is None: # pragma: no cover - import always builds it
85
+ yield "Agent failed to start; check the Space logs."
86
+ return
87
 
88
+ turns = [
89
+ {"role": m["role"], "content": m["content"]}
90
+ for m in (history or [])
91
+ if isinstance(m, dict) and m.get("role") in ("user", "assistant") and m.get("content")
92
+ ]
93
+
94
+ answer, tools, sources, plots = "", [], [], []
95
+ for event in AGENT.stream(message, turns):
96
+ kind = event["type"]
97
+ if kind == "text":
98
+ answer += event["text"]
99
+ yield answer
100
+ elif kind == "tool_end":
101
+ tools.append(event.get("tool"))
102
+ # Show tool activity while the model is still thinking, so a
103
+ # multi-step turn does not look like a hang.
104
+ yield answer + f"\n\n*running `{event.get('tool')}`…*"
105
+ elif kind == "done":
106
+ answer = event["answer"]
107
+ sources = event.get("sources") or []
108
+ plots = event.get("plots") or []
109
+
110
+ footer = ""
111
+ for plot in plots:
112
+ name = Path(plot).name
113
+ footer += f"\n\n![{name}](/gradio_api/file={PLOTS_DIR / name})"
114
+ if tools:
115
+ footer += "\n\n---\n*Computed with: " + ", ".join(f"`{t}`" for t in dict.fromkeys(tools)) + "*"
116
+ if sources:
117
+ footer += "\n\n*Sources: " + "; ".join(str(s) for s in sources[:4]) + "*"
118
+ yield answer + footer
119
+
120
+
121
+ with gr.Blocks(title="ControlAI", fill_height=True) as demo:
122
+ gr.Markdown(
123
+ "# ControlAI\n"
124
+ "Control-systems assistant. Every number in an answer comes from a deterministic "
125
+ "solver — SciPy/LAPACK/CVXPY behind a validated tool registry — never from the "
126
+ "model's own arithmetic, and conceptual answers are grounded in a local "
127
+ "control-theory corpus.\n\n"
128
+ "*This hosted demo runs on Hugging Face's hardware, so the offline guarantee of a "
129
+ "local install does not apply here — don't enter anything confidential. The full "
130
+ "app, with its own console, runs on Apple Silicon: see the repository.*"
131
+ )
132
+ gr.ChatInterface(
133
+ fn=respond,
134
+ type="messages",
135
+ examples=[
136
+ "Design an LQR for A=[[0,1],[-2,-3]], B=[[0],[1]], Q=eye(2), R=1.",
137
+ "What is the phase margin of G(s) = 10/(s(s+1)(s+5))?",
138
+ "Explain the Bode sensitivity integral and what it implies for loop shaping.",
139
+ "Place the poles of A=[[0,1],[0,0]], B=[[0],[1]] at -2 and -3.",
140
+ ],
141
+ cache_examples=False,
142
+ )
143
+
144
+
145
+ _fetch_index()
146
+ _build()
147
 
148
  if __name__ == "__main__":
149
+ PLOTS_DIR.mkdir(parents=True, exist_ok=True)
150
+ demo.launch(allowed_paths=[str(PLOTS_DIR)])
requirements-space.txt CHANGED
@@ -1,25 +1,24 @@
1
  # Hugging Face Space dependencies -- see app_space.py.
2
  #
3
- # The Space runs on plain CPU hardware. Token generation is remote (RemoteEngine
4
- # -> Inference Providers), so there is no CUDA and no local LLM here. torch is
5
- # still needed, on CPU, for the 0.6B retrieval embedder: one query is a few
6
- # hundred milliseconds, which is fine.
7
- #
8
- # Do NOT install this locally: requirements.txt is the Apple Silicon MLX runtime.
9
 
10
- # CPU wheels only. The default PyPI torch wheel drags several GB of CUDA
11
- # libraries that would never be used here.
12
- --extra-index-url https://download.pytorch.org/whl/cpu
 
 
 
 
13
  torch>=2.4.0
14
-
15
- # No upper bound: the platform appends its own gradio/spaces/uvicorn on top of
16
- # this file, and one extra constraint can make the resolve impossible. Pinning
17
- # transformers<4.56 did exactly that once -- gradio 6 needs huggingface-hub>=1.16
18
- # and every transformers<4.56 needs <1.0.
19
  transformers>=4.51.0
20
- huggingface-hub>=0.34.0 # InferenceClient.chat_completion
21
 
22
- # Deterministic numerics -- every number in an answer comes from these, locally.
 
 
 
23
  numpy>=1.24.0
24
  scipy>=1.11.0
25
  control>=0.9.4
@@ -31,9 +30,3 @@ jsonschema>=4.20.0
31
  rank-bm25>=0.2.2
32
  pypdf>=3.17.0
33
  pymupdf>=1.23.0
34
-
35
- # Web console.
36
- fastapi>=0.110.0
37
- uvicorn>=0.28.0
38
- python-multipart>=0.0.9
39
- pydantic>=2.0.0
 
1
  # Hugging Face Space dependencies -- see app_space.py.
2
  #
3
+ # The Space is a Gradio app on ZeroGPU (Linux/NVIDIA), where MLX does not exist,
4
+ # so inference goes through controlai_agent/engine_torch.py. Do NOT install this
5
+ # locally: requirements.txt on `main` is the Apple Silicon MLX runtime.
 
 
 
6
 
7
+ # Unconstrained on purpose. The platform appends its own gradio, spaces, uvicorn
8
+ # and a `torch<=2.11.0` ceiling to this file, and one extra bound of ours can
9
+ # make the resolve impossible -- `transformers<4.56` did exactly that, because
10
+ # gradio 6 needs huggingface-hub>=1.16 and every transformers<4.56 needs <1.0.
11
+ #
12
+ # Do not pin torch down to CUDA 12 either: ZeroGPU is backed by Blackwell
13
+ # (sm_120), which needs CUDA 12.8+, so an older wheel has no kernels for it.
14
  torch>=2.4.0
 
 
 
 
 
15
  transformers>=4.51.0
16
+ accelerate>=0.30.0
17
 
18
+ # gradio, spaces and huggingface-hub are injected by the platform at build time;
19
+ # listing them here only adds constraints to conflict with it.
20
+
21
+ # Deterministic numerics -- every number in an answer comes from these.
22
  numpy>=1.24.0
23
  scipy>=1.11.0
24
  control>=0.9.4
 
30
  rank-bm25>=0.2.2
31
  pypdf>=3.17.0
32
  pymupdf>=1.23.0