Spaces:
Running on Zero
add: A CUDA path for the demo Space, behind the existing engine contract
Browse filesMLX runs on Apple Silicon and nowhere else, so the public Space -- Linux/NVIDIA
on ZeroGPU -- has had no way to run the current code since the backends were
collapsed. This adds one alternative Engine implementation behind the same
contract, not an orchestrator: CONTROLAI_BACKEND=torch makes app.py build
TorchEngine instead of LocalEngine, and Embedder switches on the same variable.
Two branches. The agent loop, the registry and every tool are untouched.
engine_torch.py mirrors engine.py rather than calling model.generate, because
generate cannot express the two things that matter here: DynamicCache.crop() for
prefix reuse across tool steps, and injecting </think> to close an overrunning
reasoning block. Verified against Qwen2.5-0.5B on CPU: prefix reuse (39 of 56
prompt tokens served from cache on a follow-up turn), stop sequences, greedy
determinism, top-k/top-p/presence-penalty sampling, and the think-budget
injection all behave as the MLX engine does.
Loading is 4-bit NF4 -- Qwen3-14B is ~28GB in bf16, ~9GB quantised -- with
device_map={"": 0}. Not "auto": it inspects free VRAM at load time, which on
ZeroGPU happens before real hardware is attached to the process, and silently
offloads layers to CPU. The device is logged at startup so that is checkable.
app_space.py carries the ZeroGPU platform workarounds, each of which was
previously learned by having the Space fail to start: the @spaces.GPU function
must be module-level and wired to a real Gradio event handler or startup
validation does not see it; the probe demo must launch on its own port rather
than being mounted into the FastAPI app; and the agent must be reached through a
module global, never passed as an argument, or ZeroGPU tries to share its CUDA
tensors across a process boundary and hangs with no output.
Embedder gains the same torch path, since the Space also has to embed queries.
The index was built with the MLX 4-bit checkpoint and the Space queries with
bf16 Qwen/Qwen3-Embedding-0.6B; measured, the two agree at cosine 0.96, and
against the real 80,370-chunk index the cosine gate behaves the same -- in-domain
worst best-match 0.712 vs 0.708, off-domain best 0.528 vs 0.541. MIN_COSINE=0.62
holds unchanged, and is now overridable via CONTROLAI_MIN_COSINE if it drifts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- CLAUDE.md +41 -3
- app.py +18 -1
- app_space.py +89 -0
- controlai_agent/engine_torch.py +348 -0
- controlai_rag/embeddings.py +68 -6
- controlai_rag/retriever.py +5 -1
- requirements-space.txt +31 -0
|
@@ -11,9 +11,13 @@ never from the model's own arithmetic. A BM25 + dense hybrid retriever over a lo
|
|
| 11 |
corpus grounds conceptual answers. A FastAPI + vanilla-JS console and a terminal CLI are the two
|
| 12 |
front ends. Nothing leaves the machine.
|
| 13 |
|
| 14 |
-
Apple Silicon only. There is no
|
| 15 |
-
|
| 16 |
-
in the codebase.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
## Commands
|
| 19 |
|
|
@@ -175,6 +179,40 @@ formats correctly. `CONTROLAI_ADAPTER=<path>` loads one anyway for A/B work.
|
|
| 175 |
discovery → extraction → dataset generation → training → evaluation). That pipeline is independent
|
| 176 |
of the serving path and uses `requirements-training.txt`/`requirements-corpus.txt`.
|
| 177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
### Benchmark (`benchmarks/`)
|
| 179 |
`controlbench_v1.jsonl` is the eval set; `SCOPE.md` defines the taxonomy and `README.md` the
|
| 180 |
data-hygiene rules (never let benchmark prompts leak into training data; split by `family`, not by
|
|
|
|
| 11 |
corpus grounds conceptual answers. A FastAPI + vanilla-JS console and a terminal CLI are the two
|
| 12 |
front ends. Nothing leaves the machine.
|
| 13 |
|
| 14 |
+
Apple Silicon only for local use. There is no GGUF or Ollama path — an earlier version carried
|
| 15 |
+
both plus CUDA and a Spaces deployment, and maintaining four backends for one machine was most of
|
| 16 |
+
the complexity in the codebase.
|
| 17 |
+
|
| 18 |
+
One CUDA path came back, narrowly, for the public demo Space: see **Deployment** below. It is a
|
| 19 |
+
second `Engine` implementation behind the same contract, selected by one env var. It does not touch
|
| 20 |
+
the agent loop, the registry, or any tool.
|
| 21 |
|
| 22 |
## Commands
|
| 23 |
|
|
|
|
| 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 (huggingface.co/spaces/atakankahya/ControlAI-Agent) runs Linux/NVIDIA on ZeroGPU,
|
| 184 |
+
where MLX does not exist. `CONTROLAI_BACKEND=torch` makes `app.py::_make_engine` build
|
| 185 |
+
`TorchEngine` instead of `LocalEngine`; `Embedder` switches on the same variable. That is the whole
|
| 186 |
+
switch — two branches, no orchestrator.
|
| 187 |
+
|
| 188 |
+
`engine_torch.py` mirrors `engine.py` rather than calling `model.generate`, because `generate`
|
| 189 |
+
cannot express either of the two things that matter: `DynamicCache.crop()` for prefix reuse across
|
| 190 |
+
tool steps, and injecting `</think>` to close an overrunning reasoning block. Loading is 4-bit NF4
|
| 191 |
+
(Qwen3-14B is ~28GB bf16, ~9GB quantised) with `device_map={"": 0}` — **never `"auto"`**, which
|
| 192 |
+
inspects free VRAM at load time, before ZeroGPU has attached hardware, and silently offloads to CPU.
|
| 193 |
+
|
| 194 |
+
**ZeroGPU platform gotchas, each learned by having the Space fail:**
|
| 195 |
+
- A `@spaces.GPU` function is only detected if wired to a real Gradio event handler. One called
|
| 196 |
+
solely from a FastAPI route fails startup with "No @spaces.GPU function detected". Hence the
|
| 197 |
+
hidden probe button.
|
| 198 |
+
- That function must be a module-level `def`; nested inside `with gr.Blocks():` the detection fails.
|
| 199 |
+
- Don't `mount_gradio_app()` the probe and then run uvicorn on the same port — Gradio's own server
|
| 200 |
+
setup collides ("address already in use"). The probe launches on `port + 1`, non-blocking.
|
| 201 |
+
- **Never pass the model-holding object as an argument** to a `@spaces.GPU` function. ZeroGPU
|
| 202 |
+
marshals arguments across a process boundary and tries to share the model's CUDA tensors, failing
|
| 203 |
+
with `_share_cuda_: only available on CUDA` after emitting nothing — which reads exactly like
|
| 204 |
+
"just slow". Reach the agent through the module global.
|
| 205 |
+
|
| 206 |
+
The Space needs `HF_TOKEN` as a secret: the retrieval index is in a private dataset repo and
|
| 207 |
+
`app_space.py::_fetch_index` pulls it at startup. Without it the Space still boots, and answers
|
| 208 |
+
from model knowledge alone.
|
| 209 |
+
|
| 210 |
+
**The Space's queries are embedded with the bf16 `Qwen/Qwen3-Embedding-0.6B` while the index was
|
| 211 |
+
built with the MLX 4-bit checkpoint.** Measured, the two produce vectors agreeing at cosine 0.96,
|
| 212 |
+
and against the real 80,370-chunk index the gate behaves the same: in-domain worst best-match 0.712
|
| 213 |
+
(MLX 0.708), off-domain best 0.528 (MLX 0.541). `MIN_COSINE = 0.62` therefore holds unchanged on
|
| 214 |
+
both. `CONTROLAI_MIN_COSINE` overrides it if that ever drifts.
|
| 215 |
+
|
| 216 |
### Benchmark (`benchmarks/`)
|
| 217 |
`controlbench_v1.jsonl` is the eval set; `SCOPE.md` defines the taxonomy and `README.md` the
|
| 218 |
data-hygiene rules (never let benchmark prompts leak into training data; split by `family`, not by
|
|
@@ -16,6 +16,7 @@ from __future__ import annotations
|
|
| 16 |
|
| 17 |
import asyncio
|
| 18 |
import json
|
|
|
|
| 19 |
import queue
|
| 20 |
import shutil
|
| 21 |
import sys
|
|
@@ -56,10 +57,26 @@ inference_lock = threading.Lock()
|
|
| 56 |
_agent: ControlAgent | None = None
|
| 57 |
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
def get_agent() -> ControlAgent:
|
| 60 |
global _agent
|
| 61 |
if _agent is None:
|
| 62 |
-
_agent = ControlAgent()
|
| 63 |
return _agent
|
| 64 |
|
| 65 |
|
|
|
|
| 16 |
|
| 17 |
import asyncio
|
| 18 |
import json
|
| 19 |
+
import os
|
| 20 |
import queue
|
| 21 |
import shutil
|
| 22 |
import sys
|
|
|
|
| 57 |
_agent: ControlAgent | None = None
|
| 58 |
|
| 59 |
|
| 60 |
+
def _make_engine():
|
| 61 |
+
"""The engine for this process. MLX unless the Space asked for CUDA.
|
| 62 |
+
|
| 63 |
+
This is the *only* backend branch left in the serving path, and it exists
|
| 64 |
+
for one reason: the public demo Space runs on Linux/NVIDIA, where MLX does
|
| 65 |
+
not exist. `TorchEngine` is imported lazily so a normal Apple Silicon run
|
| 66 |
+
never needs torch installed. Everything downstream -- the agent loop, the
|
| 67 |
+
registry, every tool -- is identical either way.
|
| 68 |
+
"""
|
| 69 |
+
if os.environ.get("CONTROLAI_BACKEND", "mlx").lower() == "torch":
|
| 70 |
+
from controlai_agent.engine_torch import TorchEngine
|
| 71 |
+
|
| 72 |
+
return TorchEngine()
|
| 73 |
+
return None # ControlAgent's own default, LocalEngine
|
| 74 |
+
|
| 75 |
+
|
| 76 |
def get_agent() -> ControlAgent:
|
| 77 |
global _agent
|
| 78 |
if _agent is None:
|
| 79 |
+
_agent = ControlAgent(engine=_make_engine())
|
| 80 |
return _agent
|
| 81 |
|
| 82 |
|
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hugging Face Space entry point: the same FastAPI app, on ZeroGPU.
|
| 2 |
+
|
| 3 |
+
Not used for local runs -- `./run.sh` runs `app.py` directly. This module exists
|
| 4 |
+
because the Space's platform imposes requirements that have nothing to do with
|
| 5 |
+
ControlAI, and each of the four below was learned by having the Space fail:
|
| 6 |
+
|
| 7 |
+
1. A `@spaces.GPU`-decorated function is only recognised by the platform's
|
| 8 |
+
startup validation if it is wired to an actual Gradio event handler. A
|
| 9 |
+
function called only from a FastAPI route fails startup with "No @spaces.GPU
|
| 10 |
+
function detected". Hence the hidden probe button.
|
| 11 |
+
2. That function must be a module-level `def`. Nested inside a `with
|
| 12 |
+
gr.Blocks():` block, the same detection fails.
|
| 13 |
+
3. Do not `gr.mount_gradio_app()` the probe into the FastAPI app and then run
|
| 14 |
+
uvicorn on the same port -- Gradio's own server setup collides with it
|
| 15 |
+
("address already in use"). The probe launches on its own port; FastAPI is
|
| 16 |
+
the only thing bound to the public one.
|
| 17 |
+
4. Never pass the model-holding object as an *argument* to a `@spaces.GPU`
|
| 18 |
+
function. ZeroGPU marshals arguments across a process boundary and will try
|
| 19 |
+
to share the model's CUDA tensors, failing with `_share_cuda_: only
|
| 20 |
+
available on CUDA` after producing no output at all -- which reads exactly
|
| 21 |
+
like "just slow". Reach the agent through the module-level global instead.
|
| 22 |
+
|
| 23 |
+
The retrieval index is not in the repo (see CLAUDE.md), so it is pulled from the
|
| 24 |
+
private Hub dataset repo at startup using the Space's HF_TOKEN secret. Without
|
| 25 |
+
it the retriever has no corpus and every answer falls back to model knowledge.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
from __future__ import annotations
|
| 29 |
+
|
| 30 |
+
import os
|
| 31 |
+
|
| 32 |
+
# Must be set before app.py is imported: it decides which engine gets built.
|
| 33 |
+
os.environ.setdefault("CONTROLAI_BACKEND", "torch")
|
| 34 |
+
# Qwen3-14B in 4-bit NF4. Override with CONTROLAI_MODEL_TORCH in Space settings.
|
| 35 |
+
|
| 36 |
+
import gradio as gr
|
| 37 |
+
import spaces
|
| 38 |
+
import uvicorn
|
| 39 |
+
|
| 40 |
+
from app import app, get_agent
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _fetch_index() -> None:
|
| 44 |
+
"""Pull the retrieval index into data/rag_index/ before the agent loads."""
|
| 45 |
+
from controlai_rag.fetch_index import fetch
|
| 46 |
+
|
| 47 |
+
if not os.environ.get("HF_TOKEN"):
|
| 48 |
+
print("[space] HF_TOKEN not set -- skipping index fetch, retrieval disabled")
|
| 49 |
+
return
|
| 50 |
+
try:
|
| 51 |
+
fetch()
|
| 52 |
+
print("[space] retrieval index ready")
|
| 53 |
+
except Exception as exc: # noqa: BLE001 - a missing index must not stop boot
|
| 54 |
+
print(f"[space] could not fetch the index ({exc}); retrieval disabled")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@spaces.GPU(duration=120)
|
| 58 |
+
def _gpu_probe(text: str) -> str:
|
| 59 |
+
"""Satisfies ZeroGPU's startup validation, and warms the model on first use.
|
| 60 |
+
|
| 61 |
+
Takes only a plain string. The agent is reached through the module global --
|
| 62 |
+
see note 4 above; passing it in is what hangs the whole Space.
|
| 63 |
+
"""
|
| 64 |
+
agent = get_agent()
|
| 65 |
+
return agent.run(text).answer if text else "ready"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
with gr.Blocks() as _gpu_demo:
|
| 69 |
+
_probe_in = gr.Textbox(visible=False)
|
| 70 |
+
_probe_out = gr.Textbox(visible=False)
|
| 71 |
+
_probe_btn = gr.Button("probe", visible=False)
|
| 72 |
+
_probe_btn.click(fn=_gpu_probe, inputs=_probe_in, outputs=_probe_out)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def main() -> None:
|
| 76 |
+
_fetch_index()
|
| 77 |
+
port = int(os.environ.get("PORT", 7860))
|
| 78 |
+
# Separate port, non-blocking: see note 3.
|
| 79 |
+
_gpu_demo.launch(
|
| 80 |
+
server_name="0.0.0.0",
|
| 81 |
+
server_port=port + 1,
|
| 82 |
+
prevent_thread_lock=True,
|
| 83 |
+
share=False,
|
| 84 |
+
)
|
| 85 |
+
uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
if __name__ == "__main__":
|
| 89 |
+
main()
|
|
@@ -0,0 +1,348 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CUDA inference engine: the same contract as `LocalEngine`, on transformers.
|
| 2 |
+
|
| 3 |
+
`LocalEngine` is MLX, so it runs on Apple Silicon and nowhere else. The public
|
| 4 |
+
demo Space runs on Linux/NVIDIA (ZeroGPU), which needs a second path. This is
|
| 5 |
+
that path and nothing more -- it is not a supported way to run ControlAI locally,
|
| 6 |
+
and `agent.py`, `registry.py` and everything under `tools/` are untouched by it.
|
| 7 |
+
The seam is `ControlAgent(engine=...)`.
|
| 8 |
+
|
| 9 |
+
It mirrors `LocalEngine`'s design decisions rather than reaching for
|
| 10 |
+
`model.generate`:
|
| 11 |
+
|
| 12 |
+
* **Prefix-reusing KV cache.** `DynamicCache.crop()` trims the cache to the
|
| 13 |
+
longest prefix the incoming prompt shares with it, so the ~8k-token
|
| 14 |
+
system-prompt-plus-tool-schema prefix is prefilled once per process, not
|
| 15 |
+
once per tool step. `model.generate` cannot express that.
|
| 16 |
+
* **A hand-written decode loop.** `think_budget` closes an overrunning
|
| 17 |
+
`<think>` block by *injecting* the closing token, which no `generate`
|
| 18 |
+
callback can do, and `presence_penalty` (not `repetition_penalty`) is
|
| 19 |
+
applied for the reason spelled out in `engine.py`: a flat repetition penalty
|
| 20 |
+
punishes the `[`, `0`, `,` that matrices and JSON are made of.
|
| 21 |
+
|
| 22 |
+
Loading is 4-bit NF4 via bitsandbytes: Qwen3-14B is ~28GB in bf16 and ~9GB
|
| 23 |
+
quantised. `device_map={"": 0}` pins every layer to GPU 0 explicitly. Do not use
|
| 24 |
+
`device_map="auto"` here -- it inspects free VRAM at load time, which on ZeroGPU
|
| 25 |
+
happens *before* real hardware is attached to the process, and silently offloads
|
| 26 |
+
layers to CPU.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
import os
|
| 32 |
+
import time
|
| 33 |
+
from typing import Any, Generator, Iterable, Sequence
|
| 34 |
+
|
| 35 |
+
from controlai_agent.engine import Chunk, SamplingConfig, Stats
|
| 36 |
+
|
| 37 |
+
# The MLX default is a 4-bit MLX conversion, which transformers cannot read.
|
| 38 |
+
# This is the same weights in a format it can.
|
| 39 |
+
DEFAULT_TORCH_MODEL = os.environ.get("CONTROLAI_MODEL_TORCH", "Qwen/Qwen3-14B")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class TorchEngine:
|
| 43 |
+
"""Streaming generation against a CUDA-resident transformers model."""
|
| 44 |
+
|
| 45 |
+
def __init__(
|
| 46 |
+
self,
|
| 47 |
+
model_id: str = DEFAULT_TORCH_MODEL,
|
| 48 |
+
adapter_path: str | None = None,
|
| 49 |
+
sampling: SamplingConfig | None = None,
|
| 50 |
+
max_cache_tokens: int = 32768,
|
| 51 |
+
load_in_4bit: bool = True,
|
| 52 |
+
) -> None:
|
| 53 |
+
import torch
|
| 54 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 55 |
+
|
| 56 |
+
self.model_id = model_id
|
| 57 |
+
self.adapter_path = adapter_path
|
| 58 |
+
self.sampling = sampling or SamplingConfig()
|
| 59 |
+
self.max_cache_tokens = max_cache_tokens
|
| 60 |
+
|
| 61 |
+
t0 = time.time()
|
| 62 |
+
self.tokenizer = AutoTokenizer.from_pretrained(model_id)
|
| 63 |
+
|
| 64 |
+
# `{"": 0}` pins every layer to GPU 0 explicitly -- never "auto", see the
|
| 65 |
+
# module docstring. The CPU branch exists only so the decode loop can be
|
| 66 |
+
# exercised on a small model off a GPU box; it is far too slow to serve.
|
| 67 |
+
cuda = torch.cuda.is_available()
|
| 68 |
+
# `torch_dtype`, not `dtype`: the newer spelling only exists from
|
| 69 |
+
# transformers 4.56, and requirements-space.txt pins older than that.
|
| 70 |
+
kwargs: dict[str, Any] = {
|
| 71 |
+
"torch_dtype": torch.bfloat16 if cuda else torch.float32,
|
| 72 |
+
"device_map": {"": 0} if cuda else "cpu",
|
| 73 |
+
}
|
| 74 |
+
if load_in_4bit and not cuda:
|
| 75 |
+
load_in_4bit = False # bitsandbytes is CUDA-only
|
| 76 |
+
if load_in_4bit:
|
| 77 |
+
from transformers import BitsAndBytesConfig
|
| 78 |
+
|
| 79 |
+
kwargs["quantization_config"] = BitsAndBytesConfig(
|
| 80 |
+
load_in_4bit=True,
|
| 81 |
+
bnb_4bit_quant_type="nf4",
|
| 82 |
+
bnb_4bit_compute_dtype=torch.bfloat16,
|
| 83 |
+
# Quantising the quantisation constants too; ~0.4GB saved on a
|
| 84 |
+
# 14B model for no measurable quality cost.
|
| 85 |
+
bnb_4bit_use_double_quant=True,
|
| 86 |
+
)
|
| 87 |
+
self.model = AutoModelForCausalLM.from_pretrained(model_id, **kwargs)
|
| 88 |
+
if adapter_path:
|
| 89 |
+
from peft import PeftModel
|
| 90 |
+
|
| 91 |
+
self.model = PeftModel.from_pretrained(self.model, adapter_path)
|
| 92 |
+
self.model.eval()
|
| 93 |
+
self.load_seconds = time.time() - t0
|
| 94 |
+
# Verifiable in the Space logs: if this says cpu, device_map silently
|
| 95 |
+
# offloaded and every request will be minutes rather than seconds.
|
| 96 |
+
print(f"[torch] {model_id} on {next(self.model.parameters()).device} "
|
| 97 |
+
f"in {self.load_seconds:.1f}s")
|
| 98 |
+
|
| 99 |
+
self._cache: Any | None = None
|
| 100 |
+
self._cache_tokens: list[int] = []
|
| 101 |
+
self.last_stats = Stats()
|
| 102 |
+
|
| 103 |
+
self.supports_thinking = self._probe_thinking_support()
|
| 104 |
+
self._think_open = self._single_token("<think>")
|
| 105 |
+
self._think_close = self._single_token("</think>")
|
| 106 |
+
self._tool_open = self._single_token("<tool_call>")
|
| 107 |
+
self._tool_close = self._single_token("</tool_call>")
|
| 108 |
+
|
| 109 |
+
# ------------------------------------------------------------------ setup
|
| 110 |
+
|
| 111 |
+
def _token_ids(self, text: str) -> list[int]:
|
| 112 |
+
try:
|
| 113 |
+
return self.tokenizer.encode(text, add_special_tokens=False)
|
| 114 |
+
except TypeError:
|
| 115 |
+
return self.tokenizer.encode(text)
|
| 116 |
+
|
| 117 |
+
def _single_token(self, text: str) -> int | None:
|
| 118 |
+
ids = self._token_ids(text)
|
| 119 |
+
return ids[0] if len(ids) == 1 else None
|
| 120 |
+
|
| 121 |
+
def _probe_thinking_support(self) -> bool:
|
| 122 |
+
try:
|
| 123 |
+
self.tokenizer.apply_chat_template(
|
| 124 |
+
[{"role": "user", "content": "x"}],
|
| 125 |
+
tokenize=False,
|
| 126 |
+
add_generation_prompt=True,
|
| 127 |
+
enable_thinking=False,
|
| 128 |
+
)
|
| 129 |
+
return True
|
| 130 |
+
except (TypeError, ValueError):
|
| 131 |
+
return False
|
| 132 |
+
|
| 133 |
+
def render(
|
| 134 |
+
self,
|
| 135 |
+
messages: Sequence[dict[str, Any]],
|
| 136 |
+
tools: Sequence[dict[str, Any]] | None = None,
|
| 137 |
+
enable_thinking: bool = False,
|
| 138 |
+
) -> str:
|
| 139 |
+
kwargs: dict[str, Any] = {"tokenize": False, "add_generation_prompt": True}
|
| 140 |
+
if tools:
|
| 141 |
+
kwargs["tools"] = list(tools)
|
| 142 |
+
if self.supports_thinking:
|
| 143 |
+
kwargs["enable_thinking"] = enable_thinking
|
| 144 |
+
return self.tokenizer.apply_chat_template(list(messages), **kwargs)
|
| 145 |
+
|
| 146 |
+
def encode(self, text: str) -> list[int]:
|
| 147 |
+
return self.tokenizer.encode(text)
|
| 148 |
+
|
| 149 |
+
def count_tokens(self, text: str) -> int:
|
| 150 |
+
return len(self.tokenizer.encode(text))
|
| 151 |
+
|
| 152 |
+
# ------------------------------------------------------------------ cache
|
| 153 |
+
|
| 154 |
+
def reset_cache(self) -> None:
|
| 155 |
+
self._cache = None
|
| 156 |
+
self._cache_tokens = []
|
| 157 |
+
|
| 158 |
+
def _align_cache(self, tokens: list[int]) -> list[int]:
|
| 159 |
+
"""Trim the cache to the longest prefix it shares with `tokens`.
|
| 160 |
+
|
| 161 |
+
Returns the suffix that still has to be fed to the model. Mirrors
|
| 162 |
+
`LocalEngine._align_cache`; see that docstring for why this exists.
|
| 163 |
+
"""
|
| 164 |
+
from transformers import DynamicCache
|
| 165 |
+
|
| 166 |
+
if self._cache is None or not self._cache_tokens:
|
| 167 |
+
self._cache = DynamicCache()
|
| 168 |
+
self._cache_tokens = []
|
| 169 |
+
return list(tokens)
|
| 170 |
+
|
| 171 |
+
shared = 0
|
| 172 |
+
for a, b in zip(self._cache_tokens, tokens):
|
| 173 |
+
if a != b:
|
| 174 |
+
break
|
| 175 |
+
shared += 1
|
| 176 |
+
|
| 177 |
+
# Never keep the whole prompt: the model needs at least one token to
|
| 178 |
+
# run forward on, or there are no logits to sample from.
|
| 179 |
+
if shared >= len(tokens):
|
| 180 |
+
shared = len(tokens) - 1
|
| 181 |
+
if shared > self.max_cache_tokens:
|
| 182 |
+
shared = 0
|
| 183 |
+
|
| 184 |
+
if shared == 0:
|
| 185 |
+
self._cache = DynamicCache()
|
| 186 |
+
self._cache_tokens = []
|
| 187 |
+
return list(tokens)
|
| 188 |
+
|
| 189 |
+
if shared < len(self._cache_tokens):
|
| 190 |
+
self._cache.crop(shared)
|
| 191 |
+
self._cache_tokens = list(tokens[:shared])
|
| 192 |
+
return list(tokens[shared:])
|
| 193 |
+
|
| 194 |
+
def prewarm(self, text: str) -> int:
|
| 195 |
+
"""Prefill a prompt prefix so the first real question doesn't pay for it."""
|
| 196 |
+
import torch
|
| 197 |
+
|
| 198 |
+
tokens = self.encode(text)
|
| 199 |
+
to_feed = self._align_cache(tokens)
|
| 200 |
+
if to_feed:
|
| 201 |
+
with torch.inference_mode():
|
| 202 |
+
self.model(
|
| 203 |
+
input_ids=torch.tensor([to_feed], device=self.model.device),
|
| 204 |
+
past_key_values=self._cache,
|
| 205 |
+
use_cache=True,
|
| 206 |
+
)
|
| 207 |
+
self._cache_tokens = list(tokens)
|
| 208 |
+
return len(tokens)
|
| 209 |
+
|
| 210 |
+
# ------------------------------------------------------------- generation
|
| 211 |
+
|
| 212 |
+
def _sample(self, logits: Any, cfg: SamplingConfig, seen: set[int]) -> int:
|
| 213 |
+
import torch
|
| 214 |
+
|
| 215 |
+
logits = logits.float()
|
| 216 |
+
if cfg.presence_penalty and seen:
|
| 217 |
+
idx = torch.tensor(sorted(seen), device=logits.device)
|
| 218 |
+
logits[idx] -= cfg.presence_penalty
|
| 219 |
+
if cfg.temperature <= 0:
|
| 220 |
+
return int(torch.argmax(logits).item())
|
| 221 |
+
logits = logits / cfg.temperature
|
| 222 |
+
|
| 223 |
+
if cfg.top_k and cfg.top_k > 0:
|
| 224 |
+
kth = torch.topk(logits, min(cfg.top_k, logits.numel())).values[-1]
|
| 225 |
+
logits = logits.masked_fill(logits < kth, float("-inf"))
|
| 226 |
+
|
| 227 |
+
probs = torch.softmax(logits, dim=-1)
|
| 228 |
+
if cfg.top_p and 0 < cfg.top_p < 1:
|
| 229 |
+
ordered, order = torch.sort(probs, descending=True)
|
| 230 |
+
cumulative = torch.cumsum(ordered, dim=-1)
|
| 231 |
+
# Keep the first token that crosses top_p, so the mask is never
|
| 232 |
+
# empty even when one token already carries more than top_p mass.
|
| 233 |
+
drop = cumulative - ordered > cfg.top_p
|
| 234 |
+
ordered[drop] = 0.0
|
| 235 |
+
probs = torch.zeros_like(probs).scatter_(0, order, ordered)
|
| 236 |
+
probs = probs / probs.sum()
|
| 237 |
+
|
| 238 |
+
return int(torch.multinomial(probs, 1).item())
|
| 239 |
+
|
| 240 |
+
def stream(
|
| 241 |
+
self,
|
| 242 |
+
prompt: str | list[int],
|
| 243 |
+
sampling: SamplingConfig | None = None,
|
| 244 |
+
stop: Iterable[str] = (),
|
| 245 |
+
think_budget: int | None = None,
|
| 246 |
+
) -> Generator[Chunk, None, None]:
|
| 247 |
+
"""Yield output chunks as they are generated. See `LocalEngine.stream`."""
|
| 248 |
+
import torch
|
| 249 |
+
|
| 250 |
+
cfg = sampling or self.sampling
|
| 251 |
+
tokens = self.encode(prompt) if isinstance(prompt, str) else list(prompt)
|
| 252 |
+
|
| 253 |
+
t0 = time.time()
|
| 254 |
+
to_feed = self._align_cache(tokens)
|
| 255 |
+
self.last_stats = Stats(
|
| 256 |
+
prompt_tokens=len(tokens),
|
| 257 |
+
cached_tokens=len(tokens) - len(to_feed),
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
stop = tuple(s for s in stop if s)
|
| 261 |
+
stop_ids = {self._tool_close} if "</tool_call>" in stop and self._tool_close else set()
|
| 262 |
+
text_stops = tuple(s for s in stop if not (s == "</tool_call>" and self._tool_close))
|
| 263 |
+
window = max((len(s) for s in text_stops), default=0) + 8
|
| 264 |
+
|
| 265 |
+
eos_ids = {self.tokenizer.eos_token_id}
|
| 266 |
+
for extra in ("<|im_end|>", "<|endoftext|>"):
|
| 267 |
+
tid = self._single_token(extra)
|
| 268 |
+
if tid is not None:
|
| 269 |
+
eos_ids.add(tid)
|
| 270 |
+
eos_ids.discard(None)
|
| 271 |
+
|
| 272 |
+
emitted: list[int] = []
|
| 273 |
+
seen: set[int] = set()
|
| 274 |
+
tail = ""
|
| 275 |
+
thinking = False
|
| 276 |
+
think_tokens = 0
|
| 277 |
+
prefill_done = False
|
| 278 |
+
|
| 279 |
+
with torch.inference_mode():
|
| 280 |
+
step_input = to_feed
|
| 281 |
+
while len(emitted) < cfg.max_tokens:
|
| 282 |
+
out = self.model(
|
| 283 |
+
input_ids=torch.tensor([step_input], device=self.model.device),
|
| 284 |
+
past_key_values=self._cache,
|
| 285 |
+
use_cache=True,
|
| 286 |
+
)
|
| 287 |
+
self._cache = out.past_key_values
|
| 288 |
+
self._cache_tokens.extend(step_input)
|
| 289 |
+
if not prefill_done:
|
| 290 |
+
self.last_stats.prefill_seconds = time.time() - t0
|
| 291 |
+
t1 = time.time()
|
| 292 |
+
prefill_done = True
|
| 293 |
+
|
| 294 |
+
token = self._sample(out.logits[0, -1, :], cfg, seen)
|
| 295 |
+
if token in eos_ids:
|
| 296 |
+
break
|
| 297 |
+
|
| 298 |
+
seen.add(token)
|
| 299 |
+
emitted.append(token)
|
| 300 |
+
|
| 301 |
+
if token == self._think_open:
|
| 302 |
+
thinking, think_tokens = True, 0
|
| 303 |
+
elif token == self._think_close:
|
| 304 |
+
thinking = False
|
| 305 |
+
elif thinking:
|
| 306 |
+
think_tokens += 1
|
| 307 |
+
|
| 308 |
+
text = self.tokenizer.decode([token], skip_special_tokens=False)
|
| 309 |
+
yield Chunk(
|
| 310 |
+
text=text,
|
| 311 |
+
token=token,
|
| 312 |
+
thinking=thinking,
|
| 313 |
+
tool_call=token == self._tool_open,
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
step_input = [token]
|
| 317 |
+
|
| 318 |
+
# Overran the reasoning budget: close the block by hand and make
|
| 319 |
+
# the model answer. Bounds worst-case latency on a reasoning model.
|
| 320 |
+
if (
|
| 321 |
+
thinking
|
| 322 |
+
and think_budget
|
| 323 |
+
and think_tokens >= think_budget
|
| 324 |
+
and self._think_close is not None
|
| 325 |
+
):
|
| 326 |
+
thinking = False
|
| 327 |
+
emitted.append(self._think_close)
|
| 328 |
+
yield Chunk(text="</think>", token=self._think_close, thinking=False)
|
| 329 |
+
step_input = [token, self._think_close]
|
| 330 |
+
|
| 331 |
+
if token in stop_ids:
|
| 332 |
+
break
|
| 333 |
+
if text_stops:
|
| 334 |
+
tail = (tail + text)[-window:]
|
| 335 |
+
if any(s in tail for s in text_stops):
|
| 336 |
+
break
|
| 337 |
+
|
| 338 |
+
self.last_stats.generated_tokens = len(emitted)
|
| 339 |
+
self.last_stats.decode_seconds = time.time() - (t1 if prefill_done else t0)
|
| 340 |
+
|
| 341 |
+
def generate(
|
| 342 |
+
self,
|
| 343 |
+
prompt: str | list[int],
|
| 344 |
+
sampling: SamplingConfig | None = None,
|
| 345 |
+
stop: Iterable[str] = (),
|
| 346 |
+
think_budget: int | None = None,
|
| 347 |
+
) -> str:
|
| 348 |
+
return "".join(c.text for c in self.stream(prompt, sampling, stop, think_budget))
|
|
@@ -15,6 +15,9 @@ from pathlib import Path
|
|
| 15 |
import numpy as np
|
| 16 |
|
| 17 |
MODEL_ID = os.environ.get("CONTROLAI_EMBED_MODEL", "mlx-community/Qwen3-Embedding-0.6B-4bit-DWQ")
|
|
|
|
|
|
|
|
|
|
| 18 |
MAX_TOKENS = 512
|
| 19 |
# Qwen3-Embedding pools the hidden state at the final position, and it was
|
| 20 |
# trained with an explicit end-of-text token in that position. Omitting it is
|
|
@@ -34,31 +37,58 @@ QUERY_INSTRUCTION = (
|
|
| 34 |
class Embedder:
|
| 35 |
"""Lazily-loaded sentence embedder producing L2-normalised float32 vectors."""
|
| 36 |
|
| 37 |
-
def __init__(self, model_id: str =
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
self._model = None
|
| 40 |
self._tokenizer = None
|
| 41 |
self._eos_id: int | None = None
|
| 42 |
self._pad_id: int | None = None
|
| 43 |
|
| 44 |
def _ensure_loaded(self) -> None:
|
| 45 |
-
if self._model is None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
from mlx_lm import load
|
| 47 |
|
| 48 |
self._model, self._tokenizer = load(self.model_id)
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
|
| 53 |
@property
|
| 54 |
def dim(self) -> int:
|
| 55 |
self._ensure_loaded()
|
|
|
|
|
|
|
| 56 |
return int(self._model.args.hidden_size)
|
| 57 |
|
| 58 |
def _tokens_for(self, text: str) -> list[int]:
|
| 59 |
return self._tokenizer.encode(text)[: MAX_TOKENS - 1] + [self._eos_id]
|
| 60 |
|
| 61 |
def _encode_one(self, text: str) -> np.ndarray:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
import mlx.core as mx
|
| 63 |
|
| 64 |
ids = self._tokens_for(text)
|
|
@@ -78,6 +108,10 @@ class Embedder:
|
|
| 78 |
positions <= i, so tokens appended after the real end cannot influence
|
| 79 |
the hidden state being pooled.
|
| 80 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
import mlx.core as mx
|
| 82 |
|
| 83 |
lengths = [len(ids) for ids in batch]
|
|
@@ -89,6 +123,34 @@ class Embedder:
|
|
| 89 |
picked = picked / (mx.linalg.norm(picked, axis=-1, keepdims=True) + 1e-9)
|
| 90 |
return np.array(picked, copy=True)
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
def encode_documents(
|
| 93 |
self,
|
| 94 |
texts: list[str],
|
|
|
|
| 15 |
import numpy as np
|
| 16 |
|
| 17 |
MODEL_ID = os.environ.get("CONTROLAI_EMBED_MODEL", "mlx-community/Qwen3-Embedding-0.6B-4bit-DWQ")
|
| 18 |
+
# The Space runs on Linux, where MLX does not exist, so the same embedder has a
|
| 19 |
+
# transformers path. It is the same weights unquantised; see `Embedder._backend`.
|
| 20 |
+
TORCH_MODEL_ID = os.environ.get("CONTROLAI_EMBED_MODEL_TORCH", "Qwen/Qwen3-Embedding-0.6B")
|
| 21 |
MAX_TOKENS = 512
|
| 22 |
# Qwen3-Embedding pools the hidden state at the final position, and it was
|
| 23 |
# trained with an explicit end-of-text token in that position. Omitting it is
|
|
|
|
| 37 |
class Embedder:
|
| 38 |
"""Lazily-loaded sentence embedder producing L2-normalised float32 vectors."""
|
| 39 |
|
| 40 |
+
def __init__(self, model_id: str | None = None, backend: str | None = None) -> None:
|
| 41 |
+
# "mlx" locally, "torch" on the Space. The vectors in embeddings.npz were
|
| 42 |
+
# produced by the MLX 4-bit checkpoint; the bf16 transformers weights are
|
| 43 |
+
# the same model, so the two agree closely but not bit-exactly. If
|
| 44 |
+
# retrieval on the Space looks over- or under-eager, MIN_COSINE is the
|
| 45 |
+
# knob (CONTROLAI_MIN_COSINE), not this.
|
| 46 |
+
self._backend = (backend or os.environ.get("CONTROLAI_BACKEND", "mlx")).lower()
|
| 47 |
+
if self._backend != "torch":
|
| 48 |
+
self._backend = "mlx"
|
| 49 |
+
self.model_id = model_id or (TORCH_MODEL_ID if self._backend == "torch" else MODEL_ID)
|
| 50 |
self._model = None
|
| 51 |
self._tokenizer = None
|
| 52 |
self._eos_id: int | None = None
|
| 53 |
self._pad_id: int | None = None
|
| 54 |
|
| 55 |
def _ensure_loaded(self) -> None:
|
| 56 |
+
if self._model is not None:
|
| 57 |
+
return
|
| 58 |
+
if self._backend == "torch":
|
| 59 |
+
import torch
|
| 60 |
+
from transformers import AutoModel, AutoTokenizer
|
| 61 |
+
|
| 62 |
+
self._tokenizer = AutoTokenizer.from_pretrained(self.model_id)
|
| 63 |
+
self._model = AutoModel.from_pretrained(
|
| 64 |
+
self.model_id,
|
| 65 |
+
torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32,
|
| 66 |
+
)
|
| 67 |
+
self._model = self._model.to("cuda" if torch.cuda.is_available() else "cpu")
|
| 68 |
+
self._model.eval()
|
| 69 |
+
else:
|
| 70 |
from mlx_lm import load
|
| 71 |
|
| 72 |
self._model, self._tokenizer = load(self.model_id)
|
| 73 |
+
ids = self._tokenizer.encode(EOS_TOKEN)
|
| 74 |
+
self._eos_id = ids[-1] if ids else self._tokenizer.eos_token_id
|
| 75 |
+
self._pad_id = self._tokenizer.pad_token_id or self._eos_id
|
| 76 |
|
| 77 |
@property
|
| 78 |
def dim(self) -> int:
|
| 79 |
self._ensure_loaded()
|
| 80 |
+
if self._backend == "torch":
|
| 81 |
+
return int(self._model.config.hidden_size)
|
| 82 |
return int(self._model.args.hidden_size)
|
| 83 |
|
| 84 |
def _tokens_for(self, text: str) -> list[int]:
|
| 85 |
return self._tokenizer.encode(text)[: MAX_TOKENS - 1] + [self._eos_id]
|
| 86 |
|
| 87 |
def _encode_one(self, text: str) -> np.ndarray:
|
| 88 |
+
self._ensure_loaded()
|
| 89 |
+
if self._backend == "torch":
|
| 90 |
+
return self._encode_batch([self._tokens_for(text)])[0]
|
| 91 |
+
|
| 92 |
import mlx.core as mx
|
| 93 |
|
| 94 |
ids = self._tokens_for(text)
|
|
|
|
| 108 |
positions <= i, so tokens appended after the real end cannot influence
|
| 109 |
the hidden state being pooled.
|
| 110 |
"""
|
| 111 |
+
self._ensure_loaded()
|
| 112 |
+
if self._backend == "torch":
|
| 113 |
+
return self._encode_batch_torch(batch)
|
| 114 |
+
|
| 115 |
import mlx.core as mx
|
| 116 |
|
| 117 |
lengths = [len(ids) for ids in batch]
|
|
|
|
| 123 |
picked = picked / (mx.linalg.norm(picked, axis=-1, keepdims=True) + 1e-9)
|
| 124 |
return np.array(picked, copy=True)
|
| 125 |
|
| 126 |
+
def _encode_batch_torch(self, batch: list[list[int]]) -> np.ndarray:
|
| 127 |
+
"""`_encode_batch` on transformers. Same right-padding and same pooling.
|
| 128 |
+
|
| 129 |
+
An explicit attention mask is passed even though right-padding a causal
|
| 130 |
+
backbone is already safe, because transformers otherwise warns on every
|
| 131 |
+
call and the mask costs nothing.
|
| 132 |
+
"""
|
| 133 |
+
import torch
|
| 134 |
+
|
| 135 |
+
lengths = [len(ids) for ids in batch]
|
| 136 |
+
width = max(lengths)
|
| 137 |
+
pad = self._pad_id
|
| 138 |
+
device = self._model.device
|
| 139 |
+
ids = torch.tensor(
|
| 140 |
+
[row + [pad] * (width - len(row)) for row in batch], device=device
|
| 141 |
+
)
|
| 142 |
+
mask = torch.zeros_like(ids)
|
| 143 |
+
for i, n in enumerate(lengths):
|
| 144 |
+
mask[i, :n] = 1
|
| 145 |
+
|
| 146 |
+
with torch.inference_mode():
|
| 147 |
+
hidden = self._model(input_ids=ids, attention_mask=mask).last_hidden_state
|
| 148 |
+
picked = torch.stack(
|
| 149 |
+
[hidden[i, n - 1] for i, n in enumerate(lengths)]
|
| 150 |
+
).float()
|
| 151 |
+
picked = picked / (picked.norm(dim=-1, keepdim=True) + 1e-9)
|
| 152 |
+
return picked.cpu().numpy().astype(np.float32)
|
| 153 |
+
|
| 154 |
def encode_documents(
|
| 155 |
self,
|
| 156 |
texts: list[str],
|
|
@@ -18,6 +18,7 @@ Without it, retrieval degrades to lexical-only rather than failing.
|
|
| 18 |
from __future__ import annotations
|
| 19 |
|
| 20 |
import argparse
|
|
|
|
| 21 |
import re
|
| 22 |
from pathlib import Path
|
| 23 |
from typing import Any
|
|
@@ -39,7 +40,10 @@ EMBEDDINGS_PATH = INDEX_DIR / "embeddings.npz"
|
|
| 39 |
# 0.62 sits in that 0.108-wide gap. The off-domain outlier is a pollen-allergy
|
| 40 |
# query at 0.571, pulled up by biomedical material in the open_books tier --
|
| 41 |
# everything genuinely unrelated lands near 0.42.
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
| 43 |
# Standard RRF constant; damps the influence of any single ranker's tail.
|
| 44 |
RRF_K = 60
|
| 45 |
|
|
|
|
| 18 |
from __future__ import annotations
|
| 19 |
|
| 20 |
import argparse
|
| 21 |
+
import os
|
| 22 |
import re
|
| 23 |
from pathlib import Path
|
| 24 |
from typing import Any
|
|
|
|
| 40 |
# 0.62 sits in that 0.108-wide gap. The off-domain outlier is a pollen-allergy
|
| 41 |
# query at 0.571, pulled up by biomedical material in the open_books tier --
|
| 42 |
# everything genuinely unrelated lands near 0.42.
|
| 43 |
+
# Overridable because the Space embeds queries with the bf16 transformers
|
| 44 |
+
# checkpoint rather than the MLX 4-bit one the index was built with; the two
|
| 45 |
+
# agree closely, but this is the knob if the gate turns out mis-set there.
|
| 46 |
+
MIN_COSINE = float(os.environ.get("CONTROLAI_MIN_COSINE", "0.62"))
|
| 47 |
# Standard RRF constant; damps the influence of any single ranker's tail.
|
| 48 |
RRF_K = 60
|
| 49 |
|
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dependencies for the Hugging Face Space only -- see app_space.py.
|
| 2 |
+
#
|
| 3 |
+
# The Space runs on Linux/NVIDIA, where MLX does not exist, so inference goes
|
| 4 |
+
# through controlai_agent/engine_torch.py instead. Do NOT install this file
|
| 5 |
+
# locally: requirements.txt is the Apple Silicon runtime and is MLX-only.
|
| 6 |
+
|
| 7 |
+
torch>=2.4.0
|
| 8 |
+
# <4.56: engine_torch passes `torch_dtype=`, which the newer releases renamed.
|
| 9 |
+
transformers>=4.51.0,<4.56
|
| 10 |
+
accelerate>=0.30.0
|
| 11 |
+
bitsandbytes>=0.43.0 # 4-bit NF4; CUDA-only
|
| 12 |
+
sentencepiece>=0.2.0
|
| 13 |
+
|
| 14 |
+
gradio>=4.44.0 # only for the ZeroGPU probe
|
| 15 |
+
spaces>=0.30.0
|
| 16 |
+
huggingface-hub>=0.23.0
|
| 17 |
+
|
| 18 |
+
# Everything below is shared with requirements.txt (minus mlx-lm).
|
| 19 |
+
numpy>=1.24.0
|
| 20 |
+
scipy>=1.11.0
|
| 21 |
+
control>=0.9.4
|
| 22 |
+
cvxpy>=1.4.0
|
| 23 |
+
matplotlib>=3.7.0
|
| 24 |
+
jsonschema>=4.20.0
|
| 25 |
+
rank-bm25>=0.2.2
|
| 26 |
+
pypdf>=3.17.0
|
| 27 |
+
pymupdf>=1.23.0
|
| 28 |
+
fastapi>=0.110.0
|
| 29 |
+
uvicorn>=0.28.0
|
| 30 |
+
python-multipart>=0.0.9
|
| 31 |
+
pydantic>=2.0.0
|