Aayush Kothari commited on
Commit
3530fff
·
1 Parent(s): 33d19fb

Default endpoints to the deployed backend

Browse files
Files changed (6) hide show
  1. DEPLOY.md +0 -257
  2. app.py +0 -155
  3. backend.py +0 -545
  4. conformer.html +0 -0
  5. index.html +8 -2
  6. requirements.txt +0 -50
DEPLOY.md DELETED
@@ -1,257 +0,0 @@
1
- # Deploying Conformer
2
-
3
- ## The sizing question, first
4
-
5
- Everything downstream depends on one number, so measure it before choosing a platform.
6
-
7
- `braid_tok/tokenizer.json` in your repo has a **151-token `WordLevel` vocabulary**. At a
8
- 768-dimensional hidden size the embedding matrix is 151 × 768 ≈ **116 000 parameters**. For
9
- comparison, RoBERTa-base spends 38 M parameters on its 50k-token embedding alone. Your
10
- sequences are short too — the starter molecules tokenise to 9–21 tokens, and `MAX_LENGTH=128`
11
- is generous headroom.
12
-
13
- So BRAIDBERTa is somewhere between a few million and ~85 M parameters, running on sequences
14
- an order of magnitude shorter than natural-language models. **You do not need a GPU.** On two
15
- CPU cores a batch of 32 molecules is tens of milliseconds. A GPU would sit idle and cost
16
- $0.40–$2.00/hour to do so.
17
-
18
- Confirm the exact figure before you publish:
19
-
20
- ```python
21
- from transformers import AutoModelForMaskedLM
22
- m = AutoModelForMaskedLM.from_pretrained("aakothari/BRAIDBERTa")
23
- print(sum(p.numel() for p in m.parameters()) / 1e6, "M parameters")
24
- print(m.config)
25
- ```
26
-
27
- If that prints under ~300 M, everything below holds. If it prints over ~1 B — which would be
28
- surprising for a ZINC-100k corpus — skip to *Scale path* at the end.
29
-
30
- ---
31
-
32
- ## Recommendation
33
-
34
- > **Correction (July 2026).** An earlier version of this document recommended a
35
- > Docker Space on the free CPU tier. That is no longer available: Docker Spaces
36
- > require a paid plan, and the `cpu-basic` flavour now needs PRO. The free tier
37
- > runs **Gradio** Spaces on ZeroGPU. The recommendation below reflects that.
38
-
39
- **A Hugging Face Space using the Gradio SDK, with Conformer's FastAPI app mounted
40
- as the root application.**
41
-
42
- The reasoning that mattered before still holds: serving the page from the same
43
- app that serves the models eliminates CORS, the second deployment, environment-
44
- specific base URLs, and the browser-side token. What changes is the wrapper, not
45
- the architecture. `gr.mount_gradio_app` accepts an existing FastAPI application
46
- and returns it, so `backend.py` stays the root and is not modified at all —
47
- `app.py` is nine lines of substance around it.
48
-
49
- You end up on the free tier, on the platform where your weights already live and
50
- where reviewers look for demos.
51
-
52
- ### Comparison
53
-
54
- | Option | Verdict |
55
- |---|---|
56
- | **HF Space, Gradio SDK** | **Recommended.** Free. `app.py` wraps the unmodified backend; every route stays where it was. Hardware is ZeroGPU on the free tier — the app is CPU-bound and simply never requests the GPU, which is fine but is the one thing to verify after your first deploy. |
57
- | **HF Space, Docker SDK** | Exactly what was built, zero wrapper, but now needs a paid plan. PRO is ~$9/month and also unlocks `cpu-basic`. Worth it if you want the Dockerfile verbatim and the simplest mental model. |
58
- | **Google Cloud Run** | **Best non-HF option.** Deploys the existing Dockerfile unchanged — `PORT` is already read from the environment. Generous always-free tier, scales to zero. Needs a GCP account with billing enabled even to stay inside the free tier, which is the main friction. Cold start 10–30 s; `--min-instances=1` removes it for roughly $10–15/month. |
59
- | **Vercel frontend + backend elsewhere** | **Backend on Vercel remains a non-starter**: serverless functions cap at 250 MB unzipped and RDKit (~120 MB) + torch CPU (~200 MB) + transformers exceed that before your code loads. |
60
- | **Modal** | Good fit — scale-to-zero containers, ~$30/month in free credits, `@modal.enter()` loads weights once per container. More platform-specific code than a Space. |
61
- | **Render / Fly.io free tiers** | Memory-constrained (512 MB and 256 MB respectively by default). RDKit plus torch will not fit comfortably. Viable only on paid instances. |
62
- | **AWS App Runner / ECS Fargate** | Works, ~$15–30/month always-on, most operational overhead. Only if you are already in AWS. |
63
- | **RunPod** | GPU rental. Wrong tool — you are CPU-bound on a small model. |
64
-
65
- ### Cost
66
-
67
- | | Monthly |
68
- |---|---|
69
- | HF Space, Gradio SDK, free tier | **$0** |
70
- | HF PRO (unlocks Docker + cpu-basic) | ~$9 |
71
- | HF Space, CPU Upgrade (8 vCPU) | ~$21 |
72
- | Cloud Run, demo traffic, scale-to-zero | ~$0–5 |
73
- | Cloud Run, `min-instances=1` | ~$10–15 |
74
- | Fargate always-on | ~$15–30 |
75
- | Any GPU instance | $290+ — and unnecessary |
76
-
77
- ---
78
-
79
- ## Deployment steps
80
-
81
- Five files go in the Space: `app.py`, `backend.py`, `conformer.html`,
82
- `requirements.txt`, and `README.md` carrying the Space card frontmatter.
83
-
84
- On **huggingface.co/new-space**, choose:
85
-
86
- - **SDK** → Gradio → **Blank**
87
- - **Hardware** → ZeroGPU (the free option)
88
- - **Visibility** → Public
89
-
90
- Then:
91
-
92
- ```bash
93
- git clone https://huggingface.co/spaces/<you>/conformer
94
- cd conformer
95
- cp /path/to/{app.py,backend.py,conformer.html,requirements.txt,README.md} .
96
- git add -A && git commit -m "Conformer" && git push
97
- ```
98
-
99
- The first build takes ~10 minutes, mostly torch and RDKit. Watch the Space's
100
- **Logs** tab; you want to see `serving conformer.html`.
101
-
102
- Verify in this order — each rules out a distinct failure:
103
-
104
- ```bash
105
- curl https://<you>-conformer.hf.space/health # converters loaded?
106
- curl https://<you>-conformer.hf.space/version # right model revisions?
107
- curl -X POST https://<you>-conformer.hf.space/convert/braid \
108
- -H 'Content-Type: application/json' -d '{"smiles":"CCO"}'
109
- ```
110
-
111
- Then open the Space root and confirm the workbench renders, the backend shows
112
- **connected** on stage 02, and Embed set returns vectors that are not all
113
- identical.
114
-
115
- ### Running the Dockerfile instead
116
-
117
- The `Dockerfile` is still in the bundle and still correct. It targets a paid
118
- Docker Space or Cloud Run:
119
-
120
- ```bash
121
- # local check against the exact image that will run in production
122
- docker build -t conformer . && docker run --rm -p 7860:7860 conformer
123
-
124
- # Cloud Run
125
- gcloud run deploy conformer --source . --region us-central1 \
126
- --cpu 2 --memory 4Gi --allow-unauthenticated
127
- ```
128
-
129
- `PORT` is read from the environment, so Cloud Run needs no code change. Drop
130
- `gradio` from `requirements.txt` on this path — `backend.py` never imports it.
131
-
132
- ### If the model repos are private
133
-
134
- Add `HF_TOKEN` under **Settings → Variables and secrets**. `backend.py` reads it
135
- via `os.getenv("HF_TOKEN")` for both model loading and the `/tokenizer` route.
136
- Never put it in the Dockerfile — `ARG` and `ENV` values persist in image history.
137
-
138
- ## Code changes (already applied)
139
-
140
- 1. **Same-origin resolution.** The frontend now reads `location.origin` when served over
141
- http(s) and falls back to `http://127.0.0.1:8000` when opened as a `file://`. One build
142
- works both as a local dev loop and as a deployed Space, with no configuration.
143
-
144
- 2. **The backend serves the frontend.** `GET /` returns `conformer.html`. No second
145
- deployment, no CORS.
146
-
147
- 3. **CORS is now opt-in.** `ALLOWED_ORIGINS` (comma-separated) adds the middleware only if
148
- set. Same-origin deployment leaves it empty. The previous `allow_origins=["*"]` was fine
149
- for a local file but should never face the public internet.
150
-
151
- 4. **No browser token needed.** When the page detects it is self-hosted, the encoder stages
152
- call the backend directly. The Hugging Face token path remains for the `file://` dev case.
153
-
154
- 5. **Batch ceilings.** `MAX_BATCH` (default 64) returns 413 rather than letting one request
155
- allocate unbounded memory.
156
-
157
- 6. **Pinned model revisions.** `BRAID_REV` / `DEEP_REV` env vars are passed as `revision=` to
158
- `from_pretrained`. Default `main`; set to commit SHAs before a release.
159
-
160
- 7. **`GET /version`** returns git SHA, model repos, pinned revisions, and installed package
161
- versions.
162
-
163
- 8. **`PRELOAD=1`** loads both models at startup so the first visitor doesn't absorb it.
164
-
165
- ---
166
-
167
- ## Model weights
168
-
169
- **Do not vendor the weights into git.** They already have a canonical home on the Hub, which
170
- is versioned, mirrored and citable.
171
-
172
- The Dockerfile prefetches them at *build* time into the image layer. That trades image size
173
- (~1–2 GB) for a warm start — the right trade for a demo, where the first impression is
174
- someone clicking a link. To keep the image small instead, delete the prefetch `RUN` and the
175
- models will download on first request into `HF_HOME`.
176
-
177
- For the paper release, pin by commit SHA rather than `main`:
178
-
179
- ```bash
180
- # get the SHA
181
- python -c "from huggingface_hub import HfApi; \
182
- print(HfApi().model_info('aakothari/BRAIDBERTa').sha)"
183
-
184
- # then, in the Space settings
185
- BRAID_REV=<sha>
186
- DEEP_REV=<sha>
187
- ```
188
-
189
- `main` can move. A reader reproducing your numbers eighteen months from now needs the weights
190
- you actually used.
191
-
192
- ---
193
-
194
- ## Authentication
195
-
196
- For a public research demo the correct answer is **no user authentication**. Requiring
197
- sign-up defeats the purpose, and there is nothing sensitive behind the endpoints — users
198
- supply their own molecules and get vectors back.
199
-
200
- What you do need is abuse resistance, in this order:
201
-
202
- 1. **`MAX_BATCH`** — already in place, caps per-request work.
203
- 2. **Rate limiting** — add `slowapi` if the Space attracts scripted traffic:
204
- ```python
205
- from slowapi import Limiter
206
- from slowapi.util import get_remote_address
207
- limiter = Limiter(key_func=get_remote_address, default_limits=["60/minute"])
208
- app.state.limiter = limiter
209
- ```
210
- 3. **HF Spaces already fronts you** with its own infrastructure-level protection.
211
-
212
- Keep `HF_TOKEN` server-side as a Space secret if the repos are private. The browser should
213
- never hold a credential in the deployed configuration — and note that the token you pasted
214
- into our conversation should be rotated regardless.
215
-
216
- ---
217
-
218
- ## Reproducibility for a paper release
219
-
220
- | Artefact | Action |
221
- |---|---|
222
- | Code | Tag the release in the `braid` repo. Archive to **Zenodo** for a DOI (GitHub → Zenodo integration, then cut a release). |
223
- | Container | Pin the base image by digest: `FROM python:3.11-slim@sha256:...`. `python:3.11-slim` is a moving tag. |
224
- | Dependencies | Ship a full `pip freeze > requirements.lock.txt` alongside the top-level pins. |
225
- | Weights | Pin `BRAID_REV` / `DEEP_REV` to SHAs. Record them in the paper. |
226
- | Deployment | Spaces are git repos — the Space itself is a citable snapshot. |
227
- | Provenance at runtime | `GET /version` returns the whole set. A reviewer can check what they are looking at. |
228
- | Determinism | Note in the paper that embeddings are float32 CPU inference; results are deterministic given fixed weights and `MAX_LENGTH`, but will differ in the last decimal place from GPU inference. |
229
-
230
- Add a `CITATION.cff` to the repo root so GitHub renders a citation widget, and put the Space
231
- URL, the Zenodo DOI and the model revision SHAs in the paper's artefact statement.
232
-
233
- **One caveat to state explicitly in any write-up**: BRAID has two encoding modes, and they
234
- are not interchangeable. Record which one BRAIDBERTa was pretrained on, and make sure the
235
- demo's default matches. The workbench exposes the toggle and the dataset card records the
236
- setting, but the burden of matching it to the checkpoint is yours.
237
-
238
- ---
239
-
240
- ## Scale path
241
-
242
- Move off Spaces when either is true: sustained concurrency above roughly 4–8 simultaneous
243
- users, or the model turns out to be far larger than the tokenizer implies.
244
-
245
- Cloud Run is the natural next step because the same container image deploys unchanged:
246
-
247
- ```bash
248
- gcloud run deploy conformer \
249
- --source . --region us-central1 \
250
- --cpu 2 --memory 4Gi \
251
- --min-instances 1 \ # removes cold starts, ~$10-15/mo
252
- --max-instances 10 \
253
- --allow-unauthenticated
254
- ```
255
-
256
- Only reach for a GPU if you measure a real bottleneck. For a 151-token vocabulary on
257
- sub-30-token sequences, you almost certainly never will.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py DELETED
@@ -1,155 +0,0 @@
1
- """
2
- Entry point for a Hugging Face Space using the **Gradio SDK**.
3
-
4
- Why this file exists: Docker Spaces require a paid plan, and the free CPU
5
- flavour now needs PRO. The free tier runs Gradio Spaces on ZeroGPU. Gradio is
6
- itself built on FastAPI, and `gr.mount_gradio_app` accepts an existing FastAPI
7
- application and returns it — so Conformer's backend stays the root application,
8
- serving the workbench at `/` exactly as it does locally, with a small Gradio
9
- surface mounted at `/gradio` so the Space is recognisably a Gradio app.
10
-
11
- Nothing in backend.py changes. This is a wrapper, not a fork.
12
-
13
- Space settings: SDK = Gradio, hardware = ZeroGPU (free) or CPU basic
14
- Files required: app.py, backend.py, conformer.html, requirements.txt
15
-
16
- ZeroGPU note
17
- ------------
18
- ZeroGPU terminates any Space whose process registers no `@spaces.GPU`
19
- function at startup:
20
-
21
- runtime error: No @spaces.GPU function detected during startup
22
-
23
- Conformer is CPU-bound and does not need a GPU, so `gpu_probe` below is the
24
- one decorated function in the process. It is not a stub for its own sake: it
25
- reports the accelerator the Space was actually granted, which is the single
26
- thing DEPLOY.md says to verify after a first deploy, and it holds the GPU for
27
- a couple of seconds rather than for the lifetime of the container.
28
-
29
- The FastAPI routes stay on CPU. `backend.device()` memoises its answer at
30
- first call, when no GPU is attached, so it resolves to "cpu" and every cached
31
- model in `backend._cache` stays there. `gpu_probe` deliberately does not touch
32
- that cache — a model moved to CUDA inside a ZeroGPU window becomes unusable
33
- once the window closes.
34
- """
35
-
36
- from __future__ import annotations
37
-
38
- import os
39
-
40
- import gradio as gr
41
-
42
- # The FastAPI application, unmodified. Importing it registers every route:
43
- # /, /health, /version, /convert/*, /tokenizer/*, /models/*/pipeline/*
44
- from backend import MODELS, app as api
45
-
46
- # `spaces` exists only on Hugging Face hardware. Degrade to a no-op decorator
47
- # so this file still runs locally, on Cloud Run, and anywhere else.
48
- try:
49
- import spaces
50
-
51
- ZEROGPU = True
52
- except ImportError: # pragma: no cover - depends on host
53
- ZEROGPU = False
54
-
55
- class _Shim:
56
- @staticmethod
57
- def GPU(*args, **kwargs):
58
- def wrap(fn):
59
- return fn
60
-
61
- return wrap
62
-
63
- spaces = _Shim() # type: ignore[assignment]
64
-
65
-
66
- @spaces.GPU(duration=10)
67
- def gpu_probe() -> str:
68
- """Report the accelerator this Space was granted. Registers ZeroGPU."""
69
- try:
70
- import torch
71
- except ImportError:
72
- return "torch is not installed — encoder routes will return 501."
73
-
74
- if torch.cuda.is_available():
75
- name = torch.cuda.get_device_name(0)
76
- total = torch.cuda.get_device_properties(0).total_memory / 1e9
77
- return (
78
- f"GPU attached: {name} ({total:.0f} GB).\n\n"
79
- "Conformer does not use it — the model is small enough that CPU "
80
- "inference is faster than the round trip. The FastAPI routes at / "
81
- "run on CPU."
82
- )
83
- return (
84
- "No GPU attached; running on CPU. This is the expected and intended "
85
- "state for Conformer."
86
- )
87
-
88
-
89
- _INTRO = f"""
90
- # Conformer
91
-
92
- A browser workbench for the BRAID molecular line notation and the
93
- BRAIDBERTa / DeepBERTa encoders.
94
-
95
- ### → [Open the workbench](/)
96
-
97
- Structure parsing, drawing, descriptors and fingerprints run in your browser via
98
- RDKit's WebAssembly build. BRAID conversion and the encoders run on this Space,
99
- on CPU.
100
-
101
- **Models**
102
- - `{MODELS['braid']}`
103
- - `{MODELS['deep']}`
104
-
105
- **API** — the routes below are open, no key required.
106
-
107
- | Route | Purpose |
108
- |---|---|
109
- | `POST /convert/braid` | SMILES → BRAID, plus the token stream the model consumes |
110
- | `POST /convert/braid/decode` | BRAID → SMILES |
111
- | `POST /convert/braid/batch` | one request for a whole set |
112
- | `POST /convert/selfies` | SMILES → SELFIES |
113
- | `POST /models/{{owner}}/{{name}}/pipeline/feature-extraction` | embeddings |
114
- | `POST /models/{{owner}}/{{name}}/pipeline/fill-mask` | masked-token probe |
115
- | `GET /version` | git SHA, pinned model revisions, package versions |
116
-
117
- ```bash
118
- curl -X POST https://<this-space>.hf.space/convert/braid \\
119
- -H 'Content-Type: application/json' \\
120
- -d '{{"smiles":"CC(=O)Oc1ccccc1C(=O)O","aromatic":true}}'
121
- ```
122
- """
123
-
124
- with gr.Blocks(title="Conformer", analytics_enabled=False) as demo:
125
- gr.Markdown(_INTRO)
126
-
127
- with gr.Accordion("Hardware check", open=False):
128
- gr.Markdown(
129
- "Conformer is CPU-bound. This button confirms what the Space was "
130
- "allocated and releases it immediately."
131
- )
132
- _btn = gr.Button("Check accelerator")
133
- _out = gr.Markdown()
134
- _btn.click(gpu_probe, inputs=None, outputs=_out)
135
-
136
- # Conformer's FastAPI app stays at the root; Gradio is mounted beside it.
137
- app = gr.mount_gradio_app(api, demo, path="/gradio")
138
-
139
-
140
- if __name__ == "__main__":
141
- import uvicorn
142
-
143
- # The Space runner executes this file as a script and expects it to block
144
- # by serving; it does not launch anything itself. Remove this and the
145
- # container exits 0 having served nothing.
146
- #
147
- # On a Space, 7860 is the port the proxy forwards to, and it is the only
148
- # port that works. PORT has been observed set to 7861 in the environment,
149
- # which is held by the proxy itself — binding it fails with EADDRINUSE.
150
- # So ignore PORT here and honour it everywhere else, where it is how
151
- # Cloud Run and friends communicate the port.
152
- on_space = bool(os.getenv("SPACE_ID") or os.getenv("SPACE_REPO_ID"))
153
- port = 7860 if on_space else int(os.getenv("PORT", "7860"))
154
-
155
- uvicorn.run(app, host="0.0.0.0", port=port)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend.py DELETED
@@ -1,545 +0,0 @@
1
- """
2
- Conformer backend — optional.
3
-
4
- Serves BRAIDBERTa and DeepBERTa from your own machine using the same route
5
- shape the workbench already calls, so nothing in the frontend has to change.
6
-
7
- # converters — light, no GPU, no torch
8
- pip install "fastapi>=0.110" "uvicorn[standard]" rdkit selfies
9
- pip install git+https://github.com/AayushK-othari/braid.git
10
-
11
- # encoders — only if you want embeddings and fill-mask
12
- pip install torch transformers
13
-
14
- python backend.py
15
-
16
- torch and transformers are imported lazily, so the BRAID and SELFIES routes
17
- work on a machine with neither installed. The encoder routes return a clear
18
- 501 in that case rather than failing obscurely.
19
-
20
- Then in Conformer: header -> "Encoders offline" -> Inference endpoint:
21
-
22
- http://127.0.0.1:8000/models
23
-
24
- Why you might want this: Hugging Face's serverless inference does not host
25
- every custom architecture, and feature-extraction on a private or unusual
26
- encoder often is not available there. Running locally sidesteps that entirely,
27
- and your structures never leave the machine.
28
- """
29
-
30
- from __future__ import annotations
31
-
32
- import json
33
- import logging
34
- import os
35
- from contextlib import asynccontextmanager
36
- import subprocess
37
- from pathlib import Path
38
- from typing import Any
39
-
40
- from fastapi import FastAPI, HTTPException, Request
41
- from fastapi.middleware.cors import CORSMiddleware
42
- from fastapi.responses import FileResponse, HTMLResponse
43
- from pydantic import BaseModel
44
-
45
- logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
46
- log = logging.getLogger("conformer")
47
-
48
- MAX_LENGTH = int(os.getenv("MAX_LENGTH", "128"))
49
- MAX_BATCH = int(os.getenv("MAX_BATCH", "64")) # refuse absurd public requests
50
- HERE = Path(__file__).parent
51
-
52
- # The page is looked for beside this file. Accept the obvious names, fall back
53
- # to "the only .html in the folder", and allow an explicit override, because
54
- # "404" is a miserable thing to debug when the file is simply one directory over.
55
- FRONTEND_NAMES = ("conformer.html", "app.html", "index.html")
56
-
57
-
58
- def find_frontend() -> Path | None:
59
- override = os.getenv("FRONTEND_PATH")
60
- if override:
61
- p = Path(override).expanduser()
62
- return p if p.is_file() else None
63
- for name in FRONTEND_NAMES:
64
- p = HERE / name
65
- if p.is_file():
66
- return p
67
- loose = sorted(HERE.glob("*.html"))
68
- return loose[0] if len(loose) == 1 else None
69
-
70
- # Pin model revisions for reproducibility. Set these to commit SHAs before a
71
- # paper release so a reader gets byte-identical weights, not "whatever main is".
72
- MODELS = {
73
- "braid": os.getenv("BRAID_REPO", "aakothari/BRAIDBERTa"),
74
- "deep": os.getenv("DEEP_REPO", "aakothari/DeepBERTa_zinc_base_100k_v4"),
75
- }
76
- REVISIONS = {
77
- MODELS["braid"]: os.getenv("BRAID_REV", "main"),
78
- MODELS["deep"]: os.getenv("DEEP_REV", "main"),
79
- }
80
-
81
- # torch and transformers are imported lazily. The converter routes below are
82
- # useful on their own, and there is no reason to require a 2 GB install to
83
- # turn SMILES into BRAID.
84
- _DEVICE = None
85
-
86
-
87
- def device() -> str:
88
- global _DEVICE
89
- if _DEVICE is None:
90
- try:
91
- import torch
92
- _DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
93
- except ImportError:
94
- _DEVICE = "no-torch"
95
- return _DEVICE
96
-
97
- @asynccontextmanager
98
- async def lifespan(app: FastAPI):
99
- """PRELOAD=1 pays the model-loading cost at boot so the first user does not."""
100
- if os.getenv("PRELOAD") == "1":
101
- for repo in MODELS.values():
102
- try:
103
- load(repo)
104
- log.info("preloaded %s", repo)
105
- except Exception as exc: # noqa: BLE001
106
- log.warning("preload failed for %s: %s", repo, exc)
107
- yield
108
-
109
-
110
- app = FastAPI(title="Conformer backend", lifespan=lifespan)
111
- # A page opened from disk sends "Origin: null" and is cross-origin to
112
- # 127.0.0.1, so the local dev loop needs CORS. The default is therefore "*".
113
- #
114
- # In a same-origin deployment (this app serving conformer.html at /) no
115
- # cross-origin request happens at all, so the Dockerfile sets ALLOWED_ORIGINS=""
116
- # to drop the middleware entirely. Set it to an explicit origin list if you ever
117
- # host the page separately.
118
- _origins = [o for o in os.getenv("ALLOWED_ORIGINS", "*").split(",") if o]
119
- if _origins:
120
- app.add_middleware(
121
- CORSMiddleware,
122
- allow_origins=_origins,
123
- allow_methods=["POST", "GET", "OPTIONS"],
124
- allow_headers=["Content-Type", "Authorization"],
125
- )
126
-
127
- _cache: dict[str, tuple[Any, Any]] = {}
128
-
129
-
130
- def load(repo: str):
131
- """Load and memoise a tokenizer + masked-LM pair."""
132
- if repo not in _cache:
133
- try:
134
- from transformers import AutoModelForMaskedLM, AutoTokenizer
135
- except ImportError as exc:
136
- raise HTTPException(
137
- 501, "pip install torch transformers to serve the encoders"
138
- ) from exc
139
- log.info("loading %s on %s", repo, device())
140
- rev = REVISIONS.get(repo, "main")
141
- token = os.getenv("HF_TOKEN") or None # only needed for private repos
142
- tok = AutoTokenizer.from_pretrained(repo, revision=rev, token=token)
143
- mdl = AutoModelForMaskedLM.from_pretrained(
144
- repo, revision=rev, token=token, output_hidden_states=True
145
- )
146
- mdl.eval().to(device())
147
- _cache[repo] = (tok, mdl)
148
- return _cache[repo]
149
-
150
-
151
- class Payload(BaseModel):
152
- inputs: Any = None
153
- options: dict | None = None
154
-
155
-
156
- @app.get("/health")
157
- def health():
158
- """The workbench calls this on connect to discover which converters exist."""
159
- return {
160
- "ok": True,
161
- "device": device(),
162
- "loaded": list(_cache),
163
- "converters": {
164
- "braid": BRAID_OK,
165
- "selfies": SELFIES_OK,
166
- },
167
- }
168
-
169
-
170
- @app.post("/models/{owner}/{name}/pipeline/feature-extraction")
171
- def feature_extraction(owner: str, name: str, body: Payload):
172
- """Return [batch][sequence][hidden] — the workbench pools client-side,
173
- so mean vs CLS stays a choice you make in the interface."""
174
- repo = f"{owner}/{name}"
175
- texts = body.inputs if isinstance(body.inputs, list) else [body.inputs]
176
- texts = [str(t) for t in texts if t is not None]
177
- if not texts:
178
- raise HTTPException(400, "no inputs")
179
- if len(texts) > MAX_BATCH:
180
- raise HTTPException(413, f"batch of {len(texts)} exceeds MAX_BATCH={MAX_BATCH}")
181
- try:
182
- tok, mdl = load(repo)
183
- except HTTPException:
184
- raise # keep 501 "install torch" as-is
185
- except Exception as exc: # noqa: BLE001
186
- raise HTTPException(503, f"could not load {repo}: {exc}") from exc
187
-
188
- import torch
189
- with torch.no_grad():
190
- enc = tok(texts, padding=True, truncation=True,
191
- max_length=MAX_LENGTH, return_tensors="pt").to(device())
192
- hidden = mdl(**enc).hidden_states[-1] # (B, T, H)
193
- out = []
194
- mask = enc["attention_mask"].bool()
195
- for i in range(hidden.size(0)):
196
- keep = hidden[i][mask[i]] # drop padding so mean-pooling is honest
197
- out.append(keep.cpu().tolist())
198
- return out
199
-
200
-
201
- @app.post("/models/{owner}/{name}/pipeline/fill-mask")
202
- def fill_mask(owner: str, name: str, body: Payload):
203
- repo = f"{owner}/{name}"
204
- text = body.inputs if isinstance(body.inputs, str) else str(body.inputs)
205
- try:
206
- tok, mdl = load(repo)
207
- except HTTPException:
208
- raise # keep 501 "install torch" as-is
209
- except Exception as exc: # noqa: BLE001
210
- raise HTTPException(503, f"could not load {repo}: {exc}") from exc
211
-
212
- if tok.mask_token is None:
213
- raise HTTPException(400, f"{repo} has no mask token")
214
- # accept whichever mask spelling the user typed
215
- for alias in ("[MASK]", "<mask>", "<MASK>"):
216
- text = text.replace(alias, tok.mask_token)
217
- if tok.mask_token not in text:
218
- raise HTTPException(400, f"input contains no {tok.mask_token}")
219
-
220
- import torch
221
- with torch.no_grad():
222
- enc = tok(text, return_tensors="pt", truncation=True,
223
- max_length=MAX_LENGTH).to(device())
224
- logits = mdl(**enc).logits[0]
225
- pos = (enc["input_ids"][0] == tok.mask_token_id).nonzero()[0, 0]
226
- top = logits[pos].softmax(-1).topk(10)
227
- return [[
228
- {
229
- "token": int(idx),
230
- "token_str": tok.convert_ids_to_tokens(int(idx)),
231
- "score": float(score),
232
- }
233
- for score, idx in zip(top.values, top.indices)
234
- ]]
235
-
236
-
237
- @app.get("/tokenizer/{owner}/{name}")
238
- def tokenizer_json(owner: str, name: str):
239
- """Hand the browser a tokenizer.json for a repo.
240
-
241
- The page cannot always fetch this itself: the repo may be private, the
242
- files may not be laid out where a URL guess would find them, or the Hub may
243
- not send CORS headers for that path. This route sidesteps all three, because
244
- the server has huggingface_hub, transformers and (optionally) HF_TOKEN.
245
- """
246
- repo = f"{owner}/{name}"
247
- rev = REVISIONS.get(repo, "main")
248
- token = os.getenv("HF_TOKEN") or None
249
- tried = []
250
-
251
- # 1. the file itself, if the repo ships one
252
- try:
253
- from huggingface_hub import hf_hub_download
254
- path = hf_hub_download(repo, "tokenizer.json", revision=rev, token=token)
255
- return json.loads(Path(path).read_text(encoding="utf-8"))
256
- except Exception as exc: # noqa: BLE001
257
- tried.append(f"tokenizer.json: {exc}")
258
-
259
- # 2. rebuild it — covers repos carrying only vocab.json + merges.txt
260
- try:
261
- from transformers import AutoTokenizer
262
- tok = AutoTokenizer.from_pretrained(repo, revision=rev, token=token)
263
- backend_tok = getattr(tok, "backend_tokenizer", None)
264
- if backend_tok is None:
265
- raise RuntimeError("not a fast tokenizer, cannot serialise")
266
- return json.loads(backend_tok.to_str())
267
- except Exception as exc: # noqa: BLE001
268
- tried.append(f"AutoTokenizer: {exc}")
269
-
270
- raise HTTPException(502, f"no tokenizer for {repo} (rev {rev}) — " + " | ".join(tried))
271
-
272
-
273
- @app.get("/repo-check/{owner}/{name}")
274
- def repo_check(owner: str, name: str):
275
- """Does this repo exist, and can we see it? Distinguishes 404 from 401."""
276
- repo = f"{owner}/{name}"
277
- try:
278
- from huggingface_hub import HfApi
279
- info = HfApi().model_info(repo, token=os.getenv("HF_TOKEN") or None)
280
- return {
281
- "repo": repo, "exists": True, "private": info.private,
282
- "sha": info.sha,
283
- "files": sorted(f.rfilename for f in info.siblings)[:60],
284
- }
285
- except Exception as exc: # noqa: BLE001
286
- return {"repo": repo, "exists": False, "error": str(exc)[:300]}
287
-
288
-
289
- @app.post("/models/{owner}/{name}/pipeline/tokenize")
290
- def tokenize(owner: str, name: str, body: Payload):
291
- repo = f"{owner}/{name}"
292
- texts = body.inputs if isinstance(body.inputs, list) else [body.inputs]
293
- tok, _ = load(repo)
294
- return [{"tokens": tok.tokenize(str(t)), "ids": tok(str(t))["input_ids"]}
295
- for t in texts]
296
-
297
-
298
- # --------------------------------------------------------------------------- #
299
- # Converters
300
- #
301
- # Both of these call the *reference implementations*, not reimplementations.
302
- # BRAID in particular is not ported to JavaScript anywhere in this project:
303
- # braids/codec.py depends on RWMol, GetPeriodicTable().GetValenceList(),
304
- # AssignCIPLabels and SetDoubleBondNeighborDirections, none of which RDKit's
305
- # WebAssembly build (MinimalLib) exposes. A hand-rolled JS version would
306
- # diverge precisely at valence clamping and CIP-based stereo resolution — the
307
- # parts that are hardest to notice going wrong. So the browser asks this
308
- # server, and this server runs your code.
309
- #
310
- # pip install git+https://github.com/AayushK-othari/braid.git
311
- # pip install selfies
312
- # --------------------------------------------------------------------------- #
313
-
314
- try:
315
- from braids import braid_to_smiles, smiles_to_braid
316
- from braids.tokenizer import tokenize as braid_tokenize
317
- BRAID_OK = True
318
- except ImportError: # pragma: no cover
319
- BRAID_OK = False
320
- log.warning("braids not installed — /convert/braid will return 501")
321
-
322
- try:
323
- import selfies as sf
324
- SELFIES_OK = True
325
- except ImportError: # pragma: no cover
326
- SELFIES_OK = False
327
- log.warning("selfies not installed — /convert/selfies will return 501")
328
-
329
-
330
- class ConvertIn(BaseModel):
331
- smiles: str | None = None
332
- text: str | None = None # for decode
333
- aromatic: bool = False # BRAID Kekule vs aromatic mode
334
- clamp: bool = True # BRAID decoder valence clamping
335
-
336
-
337
- class BatchIn(BaseModel):
338
- smiles: list[str] = []
339
- aromatic: bool = False
340
-
341
-
342
- def _need(flag: bool, pkg: str, hint: str):
343
- if not flag:
344
- raise HTTPException(501, f"{pkg} is not installed — {hint}")
345
-
346
-
347
- @app.post("/convert/braid")
348
- def convert_braid(body: ConvertIn):
349
- """SMILES -> BRAID, via braids.smiles_to_braid.
350
-
351
- `aromatic` picks the mode. They are NOT interchangeable: whichever you
352
- pretrained on is the one you must encode with at inference time.
353
- """
354
- _need(BRAID_OK, "braids", "pip install git+https://github.com/AayushK-othari/braid.git")
355
- smi = body.smiles or body.text
356
- if not smi:
357
- raise HTTPException(400, "no smiles supplied")
358
- try:
359
- braid = smiles_to_braid(smi, aromatic=body.aromatic)
360
- except Exception as exc: # noqa: BLE001
361
- raise HTTPException(400, f"could not encode: {exc}") from exc
362
- toks = braid_tokenize(braid)
363
- # BRAIDBERTa's tokenizer is WordLevel + WhitespaceSplit over a 151-token
364
- # vocabulary, so the model input is the SPACE-JOINED token stream, not the
365
- # raw BRAID string. Feed it the raw string and WhitespaceSplit sees one
366
- # unknown word and the whole molecule collapses to <unk>.
367
- return {"result": braid, "tokens": " ".join(toks), "n_tokens": len(toks)}
368
-
369
-
370
- @app.post("/convert/braid/decode")
371
- def decode_braid(body: ConvertIn):
372
- """BRAID -> SMILES. With clamp=True every string decodes to something
373
- sanitizable, which is the whole point of the notation."""
374
- _need(BRAID_OK, "braids", "pip install git+https://github.com/AayushK-othari/braid.git")
375
- s = body.text or body.smiles
376
- if not s:
377
- raise HTTPException(400, "no braid string supplied")
378
- try:
379
- return {"result": braid_to_smiles(s, clamp=body.clamp)}
380
- except Exception as exc: # noqa: BLE001
381
- raise HTTPException(400, f"could not decode: {exc}") from exc
382
-
383
-
384
- @app.post("/convert/braid/batch")
385
- def batch_braid(body: BatchIn):
386
- """One request per set instead of one per molecule."""
387
- _need(BRAID_OK, "braids", "pip install git+https://github.com/AayushK-othari/braid.git")
388
- if len(body.smiles) > MAX_BATCH:
389
- raise HTTPException(413, f"batch exceeds MAX_BATCH={MAX_BATCH}")
390
- out, toks, errs = [], [], []
391
- for smi in body.smiles:
392
- try:
393
- b = smiles_to_braid(smi, aromatic=body.aromatic)
394
- out.append(b)
395
- toks.append(" ".join(braid_tokenize(b)))
396
- errs.append(None)
397
- except Exception as exc: # noqa: BLE001
398
- out.append(None)
399
- toks.append(None)
400
- errs.append(str(exc))
401
- return {"results": out, "tokens": toks, "errors": errs}
402
-
403
-
404
- @app.post("/convert/braid/tokens")
405
- def braid_tokens(body: ConvertIn):
406
- """BRAID string -> the token stream the model actually consumes."""
407
- _need(BRAID_OK, "braids", "pip install git+https://github.com/AayushK-othari/braid.git")
408
- s = body.text or body.smiles
409
- if not s:
410
- raise HTTPException(400, "no braid string supplied")
411
- toks = braid_tokenize(s)
412
- return {"tokens": toks, "text": " ".join(toks), "n_tokens": len(toks)}
413
-
414
-
415
- @app.post("/convert/selfies")
416
- def convert_selfies(body: ConvertIn):
417
- """SMILES -> SELFIES via the reference implementation."""
418
- _need(SELFIES_OK, "selfies", "pip install selfies")
419
- smi = body.smiles or body.text
420
- if not smi:
421
- raise HTTPException(400, "no smiles supplied")
422
- try:
423
- return {"result": sf.encoder(smi)}
424
- except Exception as exc: # noqa: BLE001
425
- raise HTTPException(400, f"could not encode: {exc}") from exc
426
-
427
-
428
- @app.post("/convert/selfies/decode")
429
- def decode_selfies(body: ConvertIn):
430
- _need(SELFIES_OK, "selfies", "pip install selfies")
431
- s = body.text or body.smiles
432
- if not s:
433
- raise HTTPException(400, "no selfies string supplied")
434
- try:
435
- return {"result": sf.decoder(s)}
436
- except Exception as exc: # noqa: BLE001
437
- raise HTTPException(400, f"could not decode: {exc}") from exc
438
-
439
-
440
- @app.post("/convert/selfies/batch")
441
- def batch_selfies(body: BatchIn):
442
- _need(SELFIES_OK, "selfies", "pip install selfies")
443
- if len(body.smiles) > MAX_BATCH:
444
- raise HTTPException(413, f"batch exceeds MAX_BATCH={MAX_BATCH}")
445
- out, errs = [], []
446
- for smi in body.smiles:
447
- try:
448
- out.append(sf.encoder(smi))
449
- errs.append(None)
450
- except Exception as exc: # noqa: BLE001
451
- out.append(None)
452
- errs.append(str(exc))
453
- return {"results": out, "errors": errs}
454
-
455
-
456
- # --------------------------------------------------------------------------- #
457
- # Static frontend + provenance
458
- #
459
- # Serving the page from this same app means same-origin: no CORS, no separate
460
- # deployment, one URL to cite.
461
- # --------------------------------------------------------------------------- #
462
-
463
- @app.get("/", response_class=HTMLResponse)
464
- def index():
465
- page = find_frontend()
466
- if page:
467
- return FileResponse(page, media_type="text/html")
468
-
469
- found = sorted(f.name for f in HERE.glob("*.html"))
470
- listing = ("<li><code>" + "</code></li><li><code>".join(found) + "</code></li>"
471
- if found else "<li><em>no .html files here at all</em></li>")
472
- return HTMLResponse(status_code=404, content=f"""<!doctype html>
473
- <meta charset="utf-8"><title>Frontend not found</title>
474
- <style>
475
- body{{background:#0B0E14;color:#AAB3C5;font:15px/1.65 system-ui,sans-serif;padding:48px;max-width:760px;margin:auto}}
476
- h1{{color:#E8ECF4;font-size:21px;margin:0 0 6px}} code{{color:#7DD3A0;font-family:ui-monospace,monospace}}
477
- .box{{background:#121722;border-left:3px solid #E0A458;padding:16px 20px;border-radius:8px;margin:20px 0}}
478
- li{{margin:4px 0}} p{{margin:12px 0}}
479
- </style>
480
- <h1>The server is running. The page is missing.</h1>
481
- <p>The backend is fine — it just cannot find the workbench HTML to serve.</p>
482
- <div class="box">
483
- <p style="margin-top:0">Looked in:<br><code>{HERE}</code></p>
484
- <p>For a file named <code>conformer.html</code>, <code>app.html</code> or <code>index.html</code>.</p>
485
- <p style="margin-bottom:0">HTML files actually in that folder:</p>
486
- <ul>{listing}</ul>
487
- </div>
488
- <p><strong>Fix:</strong> move <code>conformer.html</code> into the folder above, next to this
489
- script, and reload. Or point at it directly:</p>
490
- <p><code>set FRONTEND_PATH=C:\\path\\to\\conformer.html</code> &nbsp;(Windows cmd)<br>
491
- <code>$env:FRONTEND_PATH="C:\\path\\to\\conformer.html"</code> &nbsp;(PowerShell)<br>
492
- <code>FRONTEND_PATH=/path/to/conformer.html</code> &nbsp;(macOS / Linux)</p>
493
- <p>Everything else works meanwhile — <code>/health</code>, <code>/version</code>,
494
- <code>/convert/braid</code> and <code>/docs</code> are all up.</p>""")
495
-
496
-
497
- def _git_sha() -> str:
498
- if os.getenv("GIT_SHA"):
499
- return os.getenv("GIT_SHA")
500
- try:
501
- return subprocess.check_output(
502
- ["git", "rev-parse", "--short", "HEAD"], cwd=HERE, stderr=subprocess.DEVNULL
503
- ).decode().strip()
504
- except Exception: # noqa: BLE001
505
- return "unknown"
506
-
507
-
508
- @app.get("/version")
509
- def version():
510
- """Everything a reader needs to reproduce a result from this deployment."""
511
- import importlib.metadata as md
512
-
513
- def ver(pkg):
514
- try:
515
- return md.version(pkg)
516
- except Exception: # noqa: BLE001
517
- return None
518
-
519
- return {
520
- "git_sha": _git_sha(),
521
- "models": MODELS,
522
- "revisions": REVISIONS,
523
- "max_length": MAX_LENGTH,
524
- "max_batch": MAX_BATCH,
525
- "packages": {p: ver(p) for p in
526
- ("rdkit", "braids-mol", "selfies", "transformers", "torch", "fastapi")},
527
- }
528
-
529
-
530
- if __name__ == "__main__":
531
- import uvicorn
532
-
533
- host = os.getenv("HOST", "127.0.0.1")
534
- port = os.getenv("PORT", "8000")
535
- log.info("device: %s", device())
536
- page = find_frontend()
537
- if page:
538
- log.info("serving %s", page.name)
539
- log.info("open the workbench at http://%s:%s/", host, port)
540
- else:
541
- log.warning("no conformer.html / app.html / index.html found in %s", HERE)
542
- log.warning("put the page there, or set FRONTEND_PATH, then reload")
543
- log.info("(opening conformer.html from disk also works, but going through")
544
- log.info(" the server is same-origin and avoids CORS entirely)")
545
- uvicorn.run(app, host=os.getenv("HOST", "127.0.0.1"), port=int(os.getenv("PORT", "8000")))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
conformer.html DELETED
The diff for this file is too large to render. See raw diff
 
index.html CHANGED
@@ -947,15 +947,21 @@ const STAGES = [
947
  Opened as a local file -> fall back to a dev server on localhost. */
948
  const SAME_ORIGIN = (location.protocol === 'http:' || location.protocol === 'https:') ? location.origin : '';
949
 
 
 
 
 
 
 
950
  const S = {
951
  mols: [], nextId: 1,
952
  rdkit: null, rdkitErr: '',
953
  hfToken: '', // memory only, never persisted
954
- hfBase: SAME_ORIGIN ? SAME_ORIGIN + '/models' : 'https://router.huggingface.co/hf-inference/models',
955
  hfLegacy: 'https://api-inference.huggingface.co/models',
956
  models: { braid: 'aakothari/BRAIDBERTa', deep: 'aakothari/DeepBERTa_zinc_base_100k_v4' },
957
  adapters: { braid: null, selfies: null },
958
- backend: { base: SAME_ORIGIN || 'http://127.0.0.1:8000', ok: false, converters: {}, device: '' },
959
  braidAromatic: false, // must match what BRAIDBERTa was pretrained on
960
  tokenizers: {}, tokOrder: [],
961
  bench: null,
 
947
  Opened as a local file -> fall back to a dev server on localhost. */
948
  const SAME_ORIGIN = (location.protocol === 'http:' || location.protocol === 'https:') ? location.origin : '';
949
 
950
+ /* Deployed split: this page is a Static Space, the converters and encoders
951
+ live on a separate service. Hardcode it so a first-time visitor gets a
952
+ working workbench without touching settings. Override in settings to point
953
+ at a local backend.py. */
954
+ const DEPLOYED_BACKEND = 'https://braid-9wc2.onrender.com';
955
+
956
  const S = {
957
  mols: [], nextId: 1,
958
  rdkit: null, rdkitErr: '',
959
  hfToken: '', // memory only, never persisted
960
+ hfBase: DEPLOYED_BACKEND + '/models',
961
  hfLegacy: 'https://api-inference.huggingface.co/models',
962
  models: { braid: 'aakothari/BRAIDBERTa', deep: 'aakothari/DeepBERTa_zinc_base_100k_v4' },
963
  adapters: { braid: null, selfies: null },
964
+ backend: { base: DEPLOYED_BACKEND, ok: false, converters: {}, device: '' },
965
  braidAromatic: false, // must match what BRAIDBERTa was pretrained on
966
  tokenizers: {}, tokOrder: [],
967
  bench: null,
requirements.txt DELETED
@@ -1,50 +0,0 @@
1
- # Pinned to the stack this project was actually validated against.
2
- #
3
- # Four constraints shape these versions, all discovered the hard way:
4
- #
5
- # 1. ZeroGPU accepts only torch 2.11.0, 2.10.0, 2.9.1 or 2.8.0.
6
- # 2. gradio 6.x requires huggingface-hub >= 1.2, which transformers 4.x
7
- # forbids (< 1.0). gradio 5.x is the branch that coexists with the
8
- # transformers 4.49 stack running locally.
9
- # 3. gradio and spaces are NOT pinned here. The Space builder appends
10
- # `gradio[oauth,mcp]==<sdk_version from README.md>` and `spaces==<ver>`
11
- # to its own pip install line. Pinning either here as well gives pip two
12
- # conflicting `==` constraints on one package and the build dies with
13
- # ResolutionImpossible. The gradio version lives in README.md and
14
- # nowhere else.
15
- # 4. gradio 5.49.1 requires pydantic < 2.12, so pydantic is 2.11.10 (the
16
- # highest it allows) rather than 2.13.x. FastAPI 0.139.2 is happy with
17
- # it and backend.py uses only plain BaseModel, so nothing depends on
18
- # the difference.
19
- #
20
- # Verified by replicating the builder's exact pip invocation on python 3.12:
21
- #
22
- # pip install --dry-run --ignore-installed -r requirements.txt \
23
- # "torch<=2.11.0" "gradio[oauth,mcp]==5.49.1" \
24
- # "uvicorn>=0.14.0" "websockets>=10.4" "spaces==0.51.0"
25
- #
26
- # 102 packages, resolves clean. Re-run that before changing anything here;
27
- # it is much faster than discovering conflicts one Space build at a time.
28
- #
29
- # Do NOT bump transformers to 5.x without re-running the validation locally
30
- # first. Deploying a different major version than the one that produced your
31
- # numbers is how a discrepancy becomes unexplainable.
32
-
33
- # --- web layer -------------------------------------------------------------
34
- fastapi==0.139.2
35
- uvicorn[standard]==0.51.0
36
- pydantic==2.11.10
37
-
38
- # --- chemistry (the correctness-critical half) -----------------------------
39
- rdkit==2026.3.4
40
- selfies==2.2.0
41
- braids-mol @ git+https://github.com/AayushK-othari/braid.git@main
42
-
43
- # --- encoders --------------------------------------------------------------
44
- # No CPU-only index here: ZeroGPU expects the standard CUDA build. The app is
45
- # CPU-bound and simply never requests a GPU. The Space base image already
46
- # ships torch 2.8.0, so this pin matches rather than triggers a reinstall.
47
- torch==2.8.0
48
- transformers==4.49.0
49
- tokenizers==0.21.0
50
- huggingface-hub==0.36.2