Spaces:
Running on Zero
Running on Zero
Michael Stattelman commited on
Commit ·
21c5089
1
Parent(s): 66ee87e
Add application file
Browse files- README.md +5 -5
- app.py +13 -4
- app/main.py +1 -1
- space.py +98 -0
- tests/test_space.py +12 -2
README.md
CHANGED
|
@@ -6,7 +6,7 @@ colorTo: gray
|
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.28.0
|
| 8 |
python_version: "3.12"
|
| 9 |
-
app_file:
|
| 10 |
pinned: false
|
| 11 |
---
|
| 12 |
|
|
@@ -65,9 +65,9 @@ With Compose, set the same build arg and uncomment the `deploy` block in `docker
|
|
| 65 |
|
| 66 |
## Hugging Face Space
|
| 67 |
|
| 68 |
-
This folder is also a Gradio Space, set up for ZeroGPU hardware (it also runs on a CPU Space). `
|
| 69 |
|
| 70 |
-
On ZeroGPU a GPU is attached for each decision
|
| 71 |
|
| 72 |
There is no token on the Space: on a public Space anyone can use the page and the API. Make the Space private on Hugging Face if that is not what you want.
|
| 73 |
|
|
@@ -80,7 +80,7 @@ cd <your-space>
|
|
| 80 |
git lfs install
|
| 81 |
# copy the contents of this DecisionLab folder into it (not the folder itself), then:
|
| 82 |
git add .
|
| 83 |
-
git commit -m "DecisionLab 2.
|
| 84 |
git push
|
| 85 |
```
|
| 86 |
|
|
@@ -226,7 +226,7 @@ DecisionLab/
|
|
| 226 |
├── docker-compose.yml
|
| 227 |
├── constraints.txt # every other package, exact versions (from pip freeze)
|
| 228 |
├── .env.example # copy to .env for local settings (.env is never in the image)
|
| 229 |
-
├──
|
| 230 |
├── requirements.txt # Space dependencies
|
| 231 |
├── requirements-container.txt # container dependencies (with constraints.txt)
|
| 232 |
├── models/ # your LightDec and Arthur models, one folder each (not in the image)
|
|
|
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.28.0
|
| 8 |
python_version: "3.12"
|
| 9 |
+
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
---
|
| 12 |
|
|
|
|
| 65 |
|
| 66 |
## Hugging Face Space
|
| 67 |
|
| 68 |
+
This folder is also a Gradio Space, set up for ZeroGPU hardware (it also runs on a CPU Space). `app.py` serves the same FastAPI app as the container, so the page at `/` and the API at `/api/*` are identical. A Gradio app is mounted at `/gradio`: a small form and a `decide` endpoint for `gradio_client`, running the same decision code.
|
| 69 |
|
| 70 |
+
On ZeroGPU a GPU is attached for each decision. The Space starts serving at once and the models load in the background, as in the container. Each model's time is measured on the GPU, and the round trip also includes the GPU hand-off. Each decision uses your ZeroGPU quota, so a full scoreboard run (one decision per demo) uses more of it.
|
| 71 |
|
| 72 |
There is no token on the Space: on a public Space anyone can use the page and the API. Make the Space private on Hugging Face if that is not what you want.
|
| 73 |
|
|
|
|
| 80 |
git lfs install
|
| 81 |
# copy the contents of this DecisionLab folder into it (not the folder itself), then:
|
| 82 |
git add .
|
| 83 |
+
git commit -m "DecisionLab 2.1.1"
|
| 84 |
git push
|
| 85 |
```
|
| 86 |
|
|
|
|
| 226 |
├── docker-compose.yml
|
| 227 |
├── constraints.txt # every other package, exact versions (from pip freeze)
|
| 228 |
├── .env.example # copy to .env for local settings (.env is never in the image)
|
| 229 |
+
├── app.py # Hugging Face Space entry point (Gradio SDK, ZeroGPU)
|
| 230 |
├── requirements.txt # Space dependencies
|
| 231 |
├── requirements-container.txt # container dependencies (with constraints.txt)
|
| 232 |
├── models/ # your LightDec and Arthur models, one folder each (not in the image)
|
app.py
CHANGED
|
@@ -7,8 +7,12 @@ API endpoint for gradio_client, both running the same decision code (app.main.ru
|
|
| 7 |
ZeroGPU (Hugging Face docs, checked 2026-09-28):
|
| 8 |
- `import spaces` comes first: it patches torch so CUDA looks available everywhere; outside @spaces.GPU a CUDA
|
| 9 |
emulation mode lets models be moved to "cuda" without a real GPU. A real GPU exists only inside @spaces.GPU calls.
|
| 10 |
-
- Decorated calls run apart from the main process
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
- Each model times itself inside the GPU call, so model timings stay comparable; the GPU hand-off only adds to the
|
| 13 |
round trip.
|
| 14 |
@spaces.GPU does nothing on non-ZeroGPU hardware, so this file also runs on a CPU Space.
|
|
@@ -19,6 +23,7 @@ import spaces # noqa: I001 -- must be the first import (patches torch for Zero
|
|
| 19 |
|
| 20 |
import json
|
| 21 |
import os
|
|
|
|
| 22 |
from pathlib import Path
|
| 23 |
|
| 24 |
HERE = Path(__file__).resolve().parent
|
|
@@ -87,9 +92,13 @@ def build_blocks() -> gr.Blocks:
|
|
| 87 |
return demo
|
| 88 |
|
| 89 |
|
|
|
|
|
|
|
|
|
|
| 90 |
def main() -> None:
|
| 91 |
-
|
| 92 |
-
|
|
|
|
| 93 |
uvicorn.run(served, host=os.getenv("GRADIO_SERVER_NAME") or "0.0.0.0",
|
| 94 |
port=int(os.getenv("GRADIO_SERVER_PORT") or os.getenv("PORT") or 7860))
|
| 95 |
|
|
|
|
| 7 |
ZeroGPU (Hugging Face docs, checked 2026-09-28):
|
| 8 |
- `import spaces` comes first: it patches torch so CUDA looks available everywhere; outside @spaces.GPU a CUDA
|
| 9 |
emulation mode lets models be moved to "cuda" without a real GPU. A real GPU exists only inside @spaces.GPU calls.
|
| 10 |
+
- Decorated calls run apart from the main process: only the model-running step is decorated; the request checks and
|
| 11 |
+
the concurrency limit stay in the main process.
|
| 12 |
+
- The server starts serving AT ONCE and the models load in the background (the page shows their progress, as in the
|
| 13 |
+
container). ZeroGPU gives a Space limited time to start: loading ~2.6 GB of models before serving made it report
|
| 14 |
+
"No @spaces.GPU function detected during startup" and take port 7860 for its error page (2.1.0, 2026-09-30).
|
| 15 |
+
- Gradio's server-side rendering is off: its Node proxy would also want port 7860.
|
| 16 |
- Each model times itself inside the GPU call, so model timings stay comparable; the GPU hand-off only adds to the
|
| 17 |
round trip.
|
| 18 |
@spaces.GPU does nothing on non-ZeroGPU hardware, so this file also runs on a CPU Space.
|
|
|
|
| 23 |
|
| 24 |
import json
|
| 25 |
import os
|
| 26 |
+
import threading
|
| 27 |
from pathlib import Path
|
| 28 |
|
| 29 |
HERE = Path(__file__).resolve().parent
|
|
|
|
| 92 |
return demo
|
| 93 |
|
| 94 |
|
| 95 |
+
served = gr.mount_gradio_app(app, build_blocks(), path=GRADIO_PATH, ssr_mode=False)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
def main() -> None:
|
| 99 |
+
# Serve at once; load the models in the background (see the note above). The loader runs only once, even if the
|
| 100 |
+
# lab's own startup hook also starts it.
|
| 101 |
+
threading.Thread(target=load_all, name="model-loader", daemon=True).start()
|
| 102 |
uvicorn.run(served, host=os.getenv("GRADIO_SERVER_NAME") or "0.0.0.0",
|
| 103 |
port=int(os.getenv("GRADIO_SERVER_PORT") or os.getenv("PORT") or 7860))
|
| 104 |
|
app/main.py
CHANGED
|
@@ -27,7 +27,7 @@ MAX_BODY_BYTES = int(os.getenv("MAX_BODY_BYTES", str(256 * 1024)))
|
|
| 27 |
DECIDE_SLOTS = Gate(int(os.getenv("MAX_PENDING_DECIDES", "4"))) # DL-SA-004
|
| 28 |
|
| 29 |
# DL-SA-007: no /docs, /redoc or /openapi.json
|
| 30 |
-
VERSION = "2.1.
|
| 31 |
PAGE = stamp_assets((STATIC / "index.html").read_text(encoding="utf-8"), VERSION)
|
| 32 |
|
| 33 |
|
|
|
|
| 27 |
DECIDE_SLOTS = Gate(int(os.getenv("MAX_PENDING_DECIDES", "4"))) # DL-SA-004
|
| 28 |
|
| 29 |
# DL-SA-007: no /docs, /redoc or /openapi.json
|
| 30 |
+
VERSION = "2.1.1"
|
| 31 |
PAGE = stamp_assets((STATIC / "index.html").read_text(encoding="utf-8"), VERSION)
|
| 32 |
|
| 33 |
|
space.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DecisionLab on a Hugging Face Space (Gradio SDK, ZeroGPU hardware).
|
| 2 |
+
|
| 3 |
+
The Space runs this file. It serves the SAME FastAPI app as the container, so the lab page at "/" (HTML, CSS and
|
| 4 |
+
JavaScript) and /api/* are the container's. A Gradio app is mounted beside it at /gradio: a small form and a "decide"
|
| 5 |
+
API endpoint for gradio_client, both running the same decision code (app.main.run_decision).
|
| 6 |
+
|
| 7 |
+
ZeroGPU (Hugging Face docs, checked 2026-09-28):
|
| 8 |
+
- `import spaces` comes first: it patches torch so CUDA looks available everywhere; outside @spaces.GPU a CUDA
|
| 9 |
+
emulation mode lets models be moved to "cuda" without a real GPU. A real GPU exists only inside @spaces.GPU calls.
|
| 10 |
+
- Decorated calls run apart from the main process, so all models are loaded BEFORE the server starts, and only the
|
| 11 |
+
model-running step is decorated. The request checks and the concurrency limit stay in the main process.
|
| 12 |
+
- Each model times itself inside the GPU call, so model timings stay comparable; the GPU hand-off only adds to the
|
| 13 |
+
round trip.
|
| 14 |
+
@spaces.GPU does nothing on non-ZeroGPU hardware, so this file also runs on a CPU Space.
|
| 15 |
+
|
| 16 |
+
No token auth anywhere (operator ruling 2026-09-28): on a public Space, anyone can use the page and the API.
|
| 17 |
+
"""
|
| 18 |
+
import spaces # noqa: I001 -- must be the first import (patches torch for ZeroGPU)
|
| 19 |
+
|
| 20 |
+
import json
|
| 21 |
+
import os
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
HERE = Path(__file__).resolve().parent
|
| 25 |
+
os.environ.setdefault("MODELS_DIR", str(HERE / "models")) # the Space's own models/ folder
|
| 26 |
+
os.environ.setdefault("DLAB_WARMUP", "0") # no real GPU outside @spaces.GPU: no warm-up run
|
| 27 |
+
|
| 28 |
+
import gradio as gr # noqa: E402
|
| 29 |
+
import uvicorn # noqa: E402
|
| 30 |
+
|
| 31 |
+
from app import main as lab # noqa: E402
|
| 32 |
+
from app.main import VERSION, Busy, app, run_decision # noqa: E402
|
| 33 |
+
from app.models import load_all # noqa: E402
|
| 34 |
+
|
| 35 |
+
GPU_SECONDS = int(os.getenv("GPU_SECONDS", "60")) # longest a single decision may hold the GPU
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@spaces.GPU(duration=GPU_SECONDS)
|
| 39 |
+
def run_models_on_gpu(state, questions, keys):
|
| 40 |
+
return lab.run_models(state, questions, keys)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
lab.RUN_MODELS = run_models_on_gpu
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
GRADIO_PATH = "/gradio"
|
| 47 |
+
EXAMPLE_STATE = "I was charged twice for March. Refund the duplicate or I cancel."
|
| 48 |
+
EXAMPLE_QUESTIONS = json.dumps({
|
| 49 |
+
"dept": {"type": "choice", "instructions": "Which team?",
|
| 50 |
+
"criteria": {"billing": "Payments and refunds", "tech": "Bugs and errors"}},
|
| 51 |
+
"churn": {"type": "noul", "instructions": "The customer threatens to leave"},
|
| 52 |
+
}, indent=2)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def decide(state: str, questions_json: str, models: str = "") -> dict:
|
| 56 |
+
"""Run one decision. state: text, or a JSON object. questions_json: the questions as JSON.
|
| 57 |
+
models: comma-separated model keys (see /api/status); empty runs every model."""
|
| 58 |
+
try:
|
| 59 |
+
questions = json.loads(questions_json)
|
| 60 |
+
except ValueError as exc:
|
| 61 |
+
raise gr.Error(f"Questions must be valid JSON: {exc}") from None
|
| 62 |
+
if not isinstance(questions, dict):
|
| 63 |
+
raise gr.Error("Questions must be a JSON object: {name: {type, instructions, criteria}}.")
|
| 64 |
+
text = (state or "").strip()
|
| 65 |
+
try:
|
| 66 |
+
parsed = json.loads(text) if text.startswith("{") else text
|
| 67 |
+
except ValueError:
|
| 68 |
+
parsed = text
|
| 69 |
+
keys = [k.strip() for k in (models or "").split(",") if k.strip()] or None
|
| 70 |
+
try:
|
| 71 |
+
return run_decision(parsed, questions, keys)
|
| 72 |
+
except (ValueError, Busy) as exc:
|
| 73 |
+
raise gr.Error(str(exc)) from None
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def build_blocks() -> gr.Blocks:
|
| 77 |
+
with gr.Blocks(title="DecisionLab API") as demo:
|
| 78 |
+
gr.Markdown(f"# DecisionLab {VERSION}: Gradio API\n"
|
| 79 |
+
"The full lab is at the [main page](/). This form and `gradio_client` run the same decision code.")
|
| 80 |
+
with gr.Row():
|
| 81 |
+
state = gr.Textbox(label="State (text or JSON)", lines=8, value=EXAMPLE_STATE)
|
| 82 |
+
questions = gr.Code(label="Questions (JSON)", language="json", value=EXAMPLE_QUESTIONS)
|
| 83 |
+
models = gr.Textbox(label="Models (comma-separated keys from /api/status; empty = every model)", value="")
|
| 84 |
+
run = gr.Button("Run", variant="primary")
|
| 85 |
+
out = gr.JSON(label="Result")
|
| 86 |
+
run.click(decide, [state, questions, models], out, api_name="decide")
|
| 87 |
+
return demo
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def main() -> None:
|
| 91 |
+
load_all() # every model is loaded before the first request (see the note above)
|
| 92 |
+
served = gr.mount_gradio_app(app, build_blocks(), path=GRADIO_PATH)
|
| 93 |
+
uvicorn.run(served, host=os.getenv("GRADIO_SERVER_NAME") or "0.0.0.0",
|
| 94 |
+
port=int(os.getenv("GRADIO_SERVER_PORT") or os.getenv("PORT") or 7860))
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
if __name__ == "__main__":
|
| 98 |
+
main()
|
tests/test_space.py
CHANGED
|
@@ -32,7 +32,7 @@ def pins(path):
|
|
| 32 |
class SpacePackagingTest(unittest.TestCase):
|
| 33 |
def test_front_matter_declares_a_gradio_space(self):
|
| 34 |
fm = front_matter()
|
| 35 |
-
self.assertEqual((fm["sdk"], fm["app_file"]), ("gradio", "
|
| 36 |
self.assertIn(fm["python_version"], ("3.10", "3.12")) # the Pythons ZeroGPU supports
|
| 37 |
self.assertTrue((ROOT / fm["app_file"]).is_file())
|
| 38 |
self.assertEqual(fm["title"], "DecisionLab")
|
|
@@ -56,7 +56,7 @@ class SpacePackagingTest(unittest.TestCase):
|
|
| 56 |
|
| 57 |
def test_spaces_is_imported_before_anything_else(self):
|
| 58 |
import ast
|
| 59 |
-
tree = ast.parse((ROOT / "
|
| 60 |
imports = [n for n in tree.body if isinstance(n, (ast.Import, ast.ImportFrom)) and
|
| 61 |
not (isinstance(n, ast.ImportFrom) and n.module == "__future__")]
|
| 62 |
first = imports[0]
|
|
@@ -80,6 +80,16 @@ class SpacePackagingTest(unittest.TestCase):
|
|
| 80 |
with self.subTest(pattern=pattern):
|
| 81 |
self.assertRegex(attrs, rf"(?m)^{re.escape(pattern)} filter=lfs diff=lfs merge=lfs -text$")
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
def test_models_folder_exists_for_local_models(self):
|
| 84 |
self.assertTrue((ROOT / "models").is_dir())
|
| 85 |
|
|
|
|
| 32 |
class SpacePackagingTest(unittest.TestCase):
|
| 33 |
def test_front_matter_declares_a_gradio_space(self):
|
| 34 |
fm = front_matter()
|
| 35 |
+
self.assertEqual((fm["sdk"], fm["app_file"]), ("gradio", "app.py")) # Hugging Face's default name
|
| 36 |
self.assertIn(fm["python_version"], ("3.10", "3.12")) # the Pythons ZeroGPU supports
|
| 37 |
self.assertTrue((ROOT / fm["app_file"]).is_file())
|
| 38 |
self.assertEqual(fm["title"], "DecisionLab")
|
|
|
|
| 56 |
|
| 57 |
def test_spaces_is_imported_before_anything_else(self):
|
| 58 |
import ast
|
| 59 |
+
tree = ast.parse((ROOT / "app.py").read_text(encoding="utf-8"))
|
| 60 |
imports = [n for n in tree.body if isinstance(n, (ast.Import, ast.ImportFrom)) and
|
| 61 |
not (isinstance(n, ast.ImportFrom) and n.module == "__future__")]
|
| 62 |
first = imports[0]
|
|
|
|
| 80 |
with self.subTest(pattern=pattern):
|
| 81 |
self.assertRegex(attrs, rf"(?m)^{re.escape(pattern)} filter=lfs diff=lfs merge=lfs -text$")
|
| 82 |
|
| 83 |
+
def test_no_leftover_space_py(self):
|
| 84 |
+
self.assertFalse((ROOT / "space.py").exists())
|
| 85 |
+
|
| 86 |
+
def test_app_py_imports_the_app_package_not_itself(self):
|
| 87 |
+
import subprocess, sys
|
| 88 |
+
r = subprocess.run([sys.executable, "-c", "import sys; sys.path.insert(0, '.'); import app.validation; print(app.validation.__file__)"],
|
| 89 |
+
cwd=ROOT, capture_output=True, text=True)
|
| 90 |
+
self.assertEqual(r.returncode, 0, r.stderr)
|
| 91 |
+
self.assertIn("app", r.stdout)
|
| 92 |
+
|
| 93 |
def test_models_folder_exists_for_local_models(self):
|
| 94 |
self.assertTrue((ROOT / "models").is_dir())
|
| 95 |
|