atakan Claude Opus 5 commited on
Commit
1476b6c
·
1 Parent(s): ec1d77f

add: A CUDA path for the demo Space, behind the existing engine contract

Browse files

MLX 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 CHANGED
@@ -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 CUDA, GGUF, Ollama, or Hugging Face Spaces path — an earlier
15
- version carried all four, and maintaining four backends for one machine was most of the complexity
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
app.py CHANGED
@@ -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
 
app_space.py ADDED
@@ -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()
controlai_agent/engine_torch.py ADDED
@@ -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))
controlai_rag/embeddings.py CHANGED
@@ -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 = MODEL_ID) -> None:
38
- self.model_id = model_id
 
 
 
 
 
 
 
 
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
- ids = self._tokenizer.encode(EOS_TOKEN)
50
- self._eos_id = ids[-1] if ids else self._tokenizer.eos_token_id
51
- self._pad_id = self._tokenizer.pad_token_id or self._eos_id
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],
controlai_rag/retriever.py CHANGED
@@ -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
- MIN_COSINE = 0.62
 
 
 
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
 
requirements-space.txt ADDED
@@ -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