chad-code 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chad/__init__.py +7 -0
- chad/agent.py +1093 -0
- chad/base_engine.py +109 -0
- chad/bench.py +243 -0
- chad/cli.py +469 -0
- chad/compaction.py +108 -0
- chad/config.py +63 -0
- chad/diag.py +100 -0
- chad/engine.py +883 -0
- chad/guardrails.py +373 -0
- chad/ignore.py +18 -0
- chad/lsp.py +323 -0
- chad/mcp.py +719 -0
- chad/mcp_oauth.py +329 -0
- chad/openai_engine.py +252 -0
- chad/prompt.py +305 -0
- chad/render.py +462 -0
- chad/repomap.py +767 -0
- chad/session.py +281 -0
- chad/skills.py +407 -0
- chad/symbols.py +360 -0
- chad/syntaxgate.py +105 -0
- chad/toolcall_parse.py +115 -0
- chad/tools.py +962 -0
- chad/tui.py +921 -0
- chad/validate.py +361 -0
- chad_code-0.1.0.dist-info/METADATA +370 -0
- chad_code-0.1.0.dist-info/RECORD +32 -0
- chad_code-0.1.0.dist-info/WHEEL +5 -0
- chad_code-0.1.0.dist-info/entry_points.txt +4 -0
- chad_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- chad_code-0.1.0.dist-info/top_level.txt +1 -0
chad/engine.py
ADDED
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MLX inference engine for the chad backend.
|
|
3
|
+
|
|
4
|
+
The thing that makes this feel like Claude Code's backend is the **persistent prefix
|
|
5
|
+
KV cache**: each agentic turn re-renders the whole transcript but only prefills the
|
|
6
|
+
*new* tokens appended since last turn, so multi-step tool loops stay snappy instead of
|
|
7
|
+
re-reading the whole conversation every step. We keep the KV cache alive across turns
|
|
8
|
+
and extend it by diffing token ids (see `_sync_to` / `generate`).
|
|
9
|
+
|
|
10
|
+
Two decode-speed levers were investigated (see the README's throughput section):
|
|
11
|
+
|
|
12
|
+
1. **Prompt-lookup decoding (PLD)** — draft-model-free speculative decoding, gated on a
|
|
13
|
+
*trimmable* cache (`engine._trimmable`) and temp==0. It is provably greedy-identical
|
|
14
|
+
and helps trimmable models on quote-heavy work, but Ornith's hybrid SSM/attention
|
|
15
|
+
cache is non-trimmable, so PLD falls back cleanly and does not speed up the shipped
|
|
16
|
+
model. (`prompt_lookup_draft` + `_generate_prompt_lookup`.)
|
|
17
|
+
2. **Thinking budget** — `--no-think` drops Ornith's `<think>` overhead; the most
|
|
18
|
+
effective real speedup for time-to-done, since decode is bandwidth-bound (~47 tok/s).
|
|
19
|
+
|
|
20
|
+
Earlier revisions used a main+draft *speculative* layout; chad now ships a single model
|
|
21
|
+
with no draft. PLD reuses the same accept/rollback machinery without a second model.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import os
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Any, Callable, Optional
|
|
29
|
+
|
|
30
|
+
import mlx.core as mx
|
|
31
|
+
import numpy as np
|
|
32
|
+
from mlx_lm import load, stream_generate
|
|
33
|
+
from mlx_lm.models import cache as cache_utils
|
|
34
|
+
from mlx_lm.sample_utils import make_sampler
|
|
35
|
+
|
|
36
|
+
# GenStats moved to base_engine.py (plan 046) so a non-MLX backend can build one without
|
|
37
|
+
# importing mlx.core. Re-exported here so existing `from .engine import GenStats` keeps
|
|
38
|
+
# working (bench.py, tests) — the class is unchanged.
|
|
39
|
+
from . import config
|
|
40
|
+
from .base_engine import GenStats
|
|
41
|
+
from .diag import log
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _local_path(model_id: str) -> str:
|
|
45
|
+
"""Resolve a cached HF repo id to its on-disk snapshot dir so `mlx_lm.load` (and
|
|
46
|
+
`_read_config`) skip the hub revision check — a ~1s network/stat round-trip on every
|
|
47
|
+
launch, pure overhead once the weights are local. A local dir or an uncached id
|
|
48
|
+
passes through unchanged; the uncached case is downloaded by `cli._ensure_model`
|
|
49
|
+
before `load()` runs, so by then it's a cache hit here too."""
|
|
50
|
+
if os.path.isdir(model_id):
|
|
51
|
+
return model_id
|
|
52
|
+
try:
|
|
53
|
+
from huggingface_hub import try_to_load_from_cache
|
|
54
|
+
hit = try_to_load_from_cache(model_id, "config.json")
|
|
55
|
+
if isinstance(hit, str):
|
|
56
|
+
return os.path.dirname(hit)
|
|
57
|
+
except Exception: # noqa: BLE001 — never let a resolver hiccup block a real load
|
|
58
|
+
pass
|
|
59
|
+
return model_id
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def peek_context_window(model_id: str, max_context: Optional[int] = None) -> Optional[int]:
|
|
63
|
+
"""The model's context window read from `config.json` alone — no weights, no
|
|
64
|
+
tokenizer. Lets the startup banner state the window immediately while the weights
|
|
65
|
+
load in the background. Mirrors the default-path result of `_ctx_override`: the
|
|
66
|
+
native window (nested under `text_config` on VL checkpoints), YaRN-extended to
|
|
67
|
+
`max_context` when that's larger. Returns None if the config can't be read."""
|
|
68
|
+
try:
|
|
69
|
+
import json
|
|
70
|
+
path = _local_path(model_id)
|
|
71
|
+
cfg_path = os.path.join(path, "config.json")
|
|
72
|
+
if not os.path.isfile(cfg_path):
|
|
73
|
+
from huggingface_hub import hf_hub_download
|
|
74
|
+
cfg_path = hf_hub_download(model_id, "config.json")
|
|
75
|
+
with open(cfg_path) as f:
|
|
76
|
+
cfg = json.load(f)
|
|
77
|
+
native = (cfg.get("max_position_embeddings")
|
|
78
|
+
or cfg.get("text_config", {}).get("max_position_embeddings")
|
|
79
|
+
or 32768)
|
|
80
|
+
return max(native, max_context) if max_context else native
|
|
81
|
+
except Exception: # noqa: BLE001 — banner falls back to "context tbd"
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def prompt_lookup_draft(context, num_draft, ngram_max=3, ngram_min=1):
|
|
86
|
+
"""Prompt-lookup (n-gram) drafting: propose the next `num_draft` tokens by
|
|
87
|
+
finding the most recent earlier occurrence of the current suffix in `context`
|
|
88
|
+
and copying what followed it. Returns [] when nothing matches.
|
|
89
|
+
|
|
90
|
+
This is "speculative decoding with the context as the draft model": when the
|
|
91
|
+
main model is about to reproduce text already present in the conversation —
|
|
92
|
+
exactly what happens when it quotes file content into an `edit`/`write`, or
|
|
93
|
+
re-states code it just `read` — the n-gram lookup nails the continuation and a
|
|
94
|
+
single verify forward accepts a long run of tokens. Zero extra RAM, no draft
|
|
95
|
+
model, and on bandwidth-bound Apple Silicon a 10-token verify pass costs about
|
|
96
|
+
the same wall time as a 1-token step, so misses are nearly free.
|
|
97
|
+
|
|
98
|
+
Longest match wins (try ngram_max down to ngram_min); ties broken by recency
|
|
99
|
+
(rightmost occurrence), which favors the just-read file over older context.
|
|
100
|
+
"""
|
|
101
|
+
n = len(context)
|
|
102
|
+
if n < 2 or num_draft <= 0:
|
|
103
|
+
return []
|
|
104
|
+
arr = np.asarray(context)
|
|
105
|
+
hi = min(ngram_max, n - 1)
|
|
106
|
+
for ng in range(hi, ngram_min - 1, -1):
|
|
107
|
+
pat = arr[-ng:]
|
|
108
|
+
# candidate starts: positions (excluding the trailing suffix itself) whose
|
|
109
|
+
# first token matches pat[0]; verify the full n-gram, most-recent first.
|
|
110
|
+
cand = np.nonzero(arr[: n - ng] == pat[0])[0]
|
|
111
|
+
for i in cand[::-1]:
|
|
112
|
+
if np.array_equal(arr[i : i + ng], pat):
|
|
113
|
+
draft = context[i + ng : i + ng + num_draft]
|
|
114
|
+
if draft:
|
|
115
|
+
return list(draft)
|
|
116
|
+
return []
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class Engine:
|
|
121
|
+
model_id: str = "mlx-community/Qwen2.5-Coder-3B-Instruct-4bit"
|
|
122
|
+
draft_id: Optional[str] = "mlx-community/Qwen2.5-Coder-0.5B-Instruct-4bit"
|
|
123
|
+
num_draft_tokens: int = 3
|
|
124
|
+
temp: float = 0.0
|
|
125
|
+
kv_bits: Optional[int] = None # set to 8 to quantize the KV cache (saves RAM)
|
|
126
|
+
max_context: Optional[int] = None # request a context window; YaRN-extends if > native
|
|
127
|
+
prompt_lookup: bool = True # n-gram prompt-lookup speculative decoding (no draft model)
|
|
128
|
+
pld_num_draft: int = 10 # tokens to draft per forward via n-gram lookup
|
|
129
|
+
pld_ngram: int = 3 # max suffix length to match for drafting
|
|
130
|
+
# PLD on a hybrid (qwen3_5/Ornith) cache is correct (bit-exact) but OFF by default:
|
|
131
|
+
# the eval suite measured it ~2x SLOWER on realistic agentic generation. A recurrent
|
|
132
|
+
# model can't rewind to mid-forward, so every partial/total draft rejection costs an
|
|
133
|
+
# extra re-feed forward; on novel-text-heavy work (low n-gram acceptance) that's ~2
|
|
134
|
+
# forwards/token. It only wins on quote-heavy spans (re-emitting a just-read file).
|
|
135
|
+
# Opt in for that workload; the default standard path is faster for general use.
|
|
136
|
+
enable_pld_hybrid: bool = False
|
|
137
|
+
cache_dir: Optional[str] = None # ds4-style on-disk KV checkpoints; None disables
|
|
138
|
+
|
|
139
|
+
# mlx model/tokenizer/cache are loaded dynamically (mlx_lm has no stubs); annotate
|
|
140
|
+
# as Any so the gate doesn't chase attributes/calls on objects mypy can't see.
|
|
141
|
+
model: Any = field(init=False, default=None)
|
|
142
|
+
draft: Any = field(init=False, default=None)
|
|
143
|
+
tok: Any = field(init=False, default=None)
|
|
144
|
+
effective_ctx: int = field(init=False, default=32768)
|
|
145
|
+
_cache: Any = field(init=False, default=None)
|
|
146
|
+
_cached_ids: list = field(init=False, default_factory=list)
|
|
147
|
+
_trimmable: bool = field(init=False, default=False)
|
|
148
|
+
_pld_hybrid: bool = field(init=False, default=False)
|
|
149
|
+
_warm_prefix_ids: Any = field(init=False, default=None)
|
|
150
|
+
kv_bytes_per_token: float = field(init=False, default=0.0) # measured at load (036)
|
|
151
|
+
# One-deep cache quarantine stack (plan 041): push_cache stashes the live
|
|
152
|
+
# (cache, cached_ids, flags) here so a subagent can run in a fresh isolated cache;
|
|
153
|
+
# pop_cache restores it bit-identically. Depth 1 only — subagents never nest.
|
|
154
|
+
_cache_stack: list = field(init=False, default_factory=list)
|
|
155
|
+
|
|
156
|
+
def _read_config(self, repo):
|
|
157
|
+
import json
|
|
158
|
+
import os
|
|
159
|
+
local = os.path.join(repo, "config.json")
|
|
160
|
+
if os.path.isfile(local): # local model directory
|
|
161
|
+
with open(local) as f:
|
|
162
|
+
return json.load(f)
|
|
163
|
+
from huggingface_hub import hf_hub_download
|
|
164
|
+
with open(hf_hub_download(repo, "config.json")) as f:
|
|
165
|
+
return json.load(f)
|
|
166
|
+
|
|
167
|
+
def _ctx_override(self, repo):
|
|
168
|
+
"""Return (model_config_override, effective_ctx).
|
|
169
|
+
|
|
170
|
+
Uses the model's full native window by default. If max_context exceeds the
|
|
171
|
+
native window, enable YaRN rope scaling to extend it (capped at the model's
|
|
172
|
+
documented extended max), so long agentic-coding sessions fit real context.
|
|
173
|
+
"""
|
|
174
|
+
cfg = self._read_config(repo)
|
|
175
|
+
# VL checkpoints (e.g. Ornith/qwen3_5) nest the text model's real window
|
|
176
|
+
# under `text_config`; the top level omits max_position_embeddings, so a
|
|
177
|
+
# naive read silently falls back to 32768 and triggers needless compaction
|
|
178
|
+
# (= a full re-prefill on this non-trimmable cache) at ~8x too small a
|
|
179
|
+
# window. Prefer the nested value when the top level lacks it.
|
|
180
|
+
native = cfg.get("max_position_embeddings") \
|
|
181
|
+
or cfg.get("text_config", {}).get("max_position_embeddings") \
|
|
182
|
+
or 32768
|
|
183
|
+
# documented extended ceiling (Qwen ships this as the tokenizer max)
|
|
184
|
+
ceiling = getattr(self.tok, "model_max_length", native) if self.tok else native
|
|
185
|
+
if not ceiling or ceiling > 10_000_000:
|
|
186
|
+
ceiling = native
|
|
187
|
+
want = self.max_context or native
|
|
188
|
+
want = min(want, max(ceiling, native))
|
|
189
|
+
if want > native:
|
|
190
|
+
factor = round(want / native + 1e-6, 4)
|
|
191
|
+
override = {
|
|
192
|
+
"max_position_embeddings": want,
|
|
193
|
+
"rope_scaling": {
|
|
194
|
+
"type": "yarn",
|
|
195
|
+
"rope_type": "yarn",
|
|
196
|
+
"factor": factor,
|
|
197
|
+
"original_max_position_embeddings": native,
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
return override, want
|
|
201
|
+
return None, want
|
|
202
|
+
|
|
203
|
+
def load(self):
|
|
204
|
+
t0 = time.time()
|
|
205
|
+
# Resolve a cached repo id to its local snapshot dir once, then load from disk —
|
|
206
|
+
# skips the per-launch hub revision check on both the weights and _read_config.
|
|
207
|
+
path = _local_path(self.model_id)
|
|
208
|
+
# Load tokenizer first (cheap) so _ctx_override can read its documented max.
|
|
209
|
+
self.model, self.tok = load(path)
|
|
210
|
+
override, eff = self._ctx_override(path)
|
|
211
|
+
self.effective_ctx = eff
|
|
212
|
+
if override is not None:
|
|
213
|
+
# reload main with YaRN extension applied
|
|
214
|
+
self.model, self.tok = load(path, model_config=override)
|
|
215
|
+
if self.draft_id:
|
|
216
|
+
dpath = _local_path(self.draft_id)
|
|
217
|
+
d_override, _ = self._ctx_override(dpath)
|
|
218
|
+
self.draft, _ = load(dpath, model_config=d_override) if d_override \
|
|
219
|
+
else load(dpath)
|
|
220
|
+
self._reset_cache()
|
|
221
|
+
self.kv_bytes_per_token = self._measure_kv_bytes_per_token()
|
|
222
|
+
return time.time() - t0
|
|
223
|
+
|
|
224
|
+
def _measure_kv_bytes_per_token(self, probe: int = 8) -> float:
|
|
225
|
+
"""One-time: per-token bytes the growing attention KV cache costs, so the
|
|
226
|
+
RAM-aware compaction trigger (plan 036, cli.py) can size context from real
|
|
227
|
+
per-token cost instead of a magic constant — model/quant-agnostic.
|
|
228
|
+
|
|
229
|
+
Feeds a few tokens through a fresh cache and reads each KVCache layer's
|
|
230
|
+
per-token stride straight from its array shape (`nbytes / seq_len`), which is
|
|
231
|
+
exact regardless of MLX's step-padding (both numerator and denominator include
|
|
232
|
+
the padding). The 30 SSM/DeltaNet ArraysCache layers hold a fixed recurrent
|
|
233
|
+
state that does NOT grow with context, so they're excluded — only the 10
|
|
234
|
+
attention layers scale. Cheap (~0.1 s, 8-token forward); leaves a clean reset
|
|
235
|
+
cache behind. Returns 0.0 on any failure (cli then falls back to the old cap)."""
|
|
236
|
+
try:
|
|
237
|
+
self._reset_cache()
|
|
238
|
+
self._prefill(list(range(100, 100 + probe)))
|
|
239
|
+
mx.eval([c.state for c in self._cache])
|
|
240
|
+
bpt = 0.0
|
|
241
|
+
for c in self._cache:
|
|
242
|
+
if isinstance(c, cache_utils.KVCache):
|
|
243
|
+
for arr in (getattr(c, "keys", None), getattr(c, "values", None)):
|
|
244
|
+
if arr is not None and len(arr.shape) >= 3 and arr.shape[2]:
|
|
245
|
+
bpt += arr.nbytes / arr.shape[2]
|
|
246
|
+
return bpt
|
|
247
|
+
except Exception: # noqa: BLE001 — instrumentation must never break load
|
|
248
|
+
return 0.0
|
|
249
|
+
finally:
|
|
250
|
+
self._reset_cache()
|
|
251
|
+
mx.clear_cache()
|
|
252
|
+
|
|
253
|
+
# -- cache management -------------------------------------------------
|
|
254
|
+
|
|
255
|
+
def reset(self):
|
|
256
|
+
"""Public alias for `_reset_cache` (plan 046 BaseEngine seam). Consumers (TUI /
|
|
257
|
+
REPL / cli governor) call this; the private name stays the engine's own internal
|
|
258
|
+
spelling, used throughout the cache machinery below."""
|
|
259
|
+
self._reset_cache()
|
|
260
|
+
|
|
261
|
+
def _reset_cache(self):
|
|
262
|
+
self._cache = cache_utils.make_prompt_cache(self.model)
|
|
263
|
+
if self.draft is not None:
|
|
264
|
+
self._cache += cache_utils.make_prompt_cache(self.draft)
|
|
265
|
+
self._cached_ids = []
|
|
266
|
+
# Hybrid SSM/attention models (e.g. Ornith/qwen3_5) keep recurrent state
|
|
267
|
+
# that cannot be rewound, so their cache is not trimmable. KV-trim tricks
|
|
268
|
+
# (prompt-lookup decoding, partial prefix reuse) only work when it is.
|
|
269
|
+
self._set_cache_flags()
|
|
270
|
+
|
|
271
|
+
def _set_cache_flags(self):
|
|
272
|
+
"""Classify the live cache: trimmable (attention-only) vs a qwen3_5-style
|
|
273
|
+
hybrid we can still roll back specially. Called whenever self._cache is
|
|
274
|
+
replaced (reset, warm-start load, compaction reload)."""
|
|
275
|
+
self._trimmable = cache_utils.can_trim_prompt_cache(self._cache)
|
|
276
|
+
# ...BUT a qwen3_5-style hybrid is recoverable a different way: its
|
|
277
|
+
# Gated-DeltaNet layers (ArraysCache) reassign their state arrays each step
|
|
278
|
+
# rather than mutating them in place, so we can snapshot/restore those layers
|
|
279
|
+
# by *reference* (free) and roll a speculative draft back exactly — restore
|
|
280
|
+
# recurrent + native-trim the attention KV + re-feed the accepted prefix.
|
|
281
|
+
# (The trimmable PLD path's bit-exact equivalence is regression-tested in
|
|
282
|
+
# test_engine.py tier 2; this hybrid recurrent-snapshot rollback has no
|
|
283
|
+
# automated case yet — no hybrid model is loaded in the test env.) That
|
|
284
|
+
# un-gates PLD on a cache `can_trim_prompt_cache` calls non-trimmable.
|
|
285
|
+
self._pld_hybrid = (
|
|
286
|
+
not self._trimmable
|
|
287
|
+
and self.draft is None
|
|
288
|
+
and bool(self._cache)
|
|
289
|
+
and all(isinstance(c, (cache_utils.KVCache, cache_utils.ArraysCache))
|
|
290
|
+
for c in self._cache)
|
|
291
|
+
and any(isinstance(c, cache_utils.KVCache) for c in self._cache)
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# -- ds4-style on-disk KV checkpoints ---------------------------------
|
|
295
|
+
# Ornith's hybrid SSM cache is NOT trimmable, so we can never partially reuse
|
|
296
|
+
# a divergent prefix in RAM. But a STABLE prefix — the system prompt + tool
|
|
297
|
+
# schemas, byte-identical every session (~3.2k tokens) — is pure dead-weight
|
|
298
|
+
# prefill on every cold start and `/reset`. ds4 (antirez) avoids that by
|
|
299
|
+
# persisting the KV state to disk keyed by the rendered prefix and reloading it
|
|
300
|
+
# instead of re-prefilling. We do the same with mlx_lm's save/load_prompt_cache:
|
|
301
|
+
# the recurrent SSM state serializes fine (a fixed ~51MB floor; cheap for one
|
|
302
|
+
# warm-start file), and on a same-model load the state is bit-for-bit reusable.
|
|
303
|
+
|
|
304
|
+
def _ckpt_path(self, ids: list, tag: str = "") -> str:
|
|
305
|
+
h = hashlib.sha1()
|
|
306
|
+
h.update(self.model_id.encode("utf-8", "ignore"))
|
|
307
|
+
h.update(b"\x00")
|
|
308
|
+
if tag: # namespace distinct checkpoint kinds (warm-prefix vs push-spill)
|
|
309
|
+
h.update(tag.encode("utf-8", "ignore"))
|
|
310
|
+
h.update(b"\x00")
|
|
311
|
+
h.update(np.asarray(ids, dtype=np.uint32).tobytes())
|
|
312
|
+
# cache_dir is Optional on the dataclass but is always set when checkpointing
|
|
313
|
+
# is enabled, which is the only path that reaches _ckpt_path.
|
|
314
|
+
return os.path.join(self.cache_dir, h.hexdigest() + ".safetensors") # type: ignore[arg-type]
|
|
315
|
+
|
|
316
|
+
def warm_prefix(self, prefix_ids: list, should_stop=None):
|
|
317
|
+
"""Make a cold session start warm. If a disk checkpoint for exactly these
|
|
318
|
+
prefix tokens exists, load it into the live cache (ZERO prefill); otherwise
|
|
319
|
+
prefill the prefix once and persist it for next time. Only valid on a cold,
|
|
320
|
+
single-model (no-draft) cache — Ornith's normal configuration. Returns
|
|
321
|
+
(status, n_tokens) where status is 'hit' | 'miss' | 'skip'."""
|
|
322
|
+
if not self.cache_dir or not prefix_ids or self.draft is not None:
|
|
323
|
+
return ("skip", 0)
|
|
324
|
+
if self._cached_ids: # cache already populated this session
|
|
325
|
+
return ("skip", 0)
|
|
326
|
+
path = self._ckpt_path(prefix_ids)
|
|
327
|
+
if os.path.isfile(path):
|
|
328
|
+
try:
|
|
329
|
+
loaded = cache_utils.load_prompt_cache(path)
|
|
330
|
+
# sanity: a loaded cache must have one entry per model layer
|
|
331
|
+
if len(loaded) == self._n_model_layers():
|
|
332
|
+
self._cache = loaded
|
|
333
|
+
self._cached_ids = list(prefix_ids)
|
|
334
|
+
self._warm_prefix_ids = list(prefix_ids)
|
|
335
|
+
self._set_cache_flags()
|
|
336
|
+
return ("hit", len(prefix_ids))
|
|
337
|
+
except Exception:
|
|
338
|
+
pass # corrupt/incompatible -> recompute below
|
|
339
|
+
# miss: prefill the prefix into a fresh cache, then persist it.
|
|
340
|
+
self._reset_cache()
|
|
341
|
+
fed = self._prefill(list(prefix_ids), should_stop)
|
|
342
|
+
if fed < len(prefix_ids): # interrupted -> don't persist a partial
|
|
343
|
+
self._cached_ids = list(prefix_ids[:fed])
|
|
344
|
+
return ("miss", fed)
|
|
345
|
+
self._cached_ids = list(prefix_ids)
|
|
346
|
+
self._warm_prefix_ids = list(prefix_ids)
|
|
347
|
+
try:
|
|
348
|
+
os.makedirs(self.cache_dir, exist_ok=True)
|
|
349
|
+
cache_utils.save_prompt_cache(path, self._cache)
|
|
350
|
+
except Exception:
|
|
351
|
+
pass # disk full / read-only -> just skip persist
|
|
352
|
+
return ("miss", len(prefix_ids))
|
|
353
|
+
|
|
354
|
+
def _n_model_layers(self) -> int:
|
|
355
|
+
return len(self.model.layers)
|
|
356
|
+
|
|
357
|
+
def _sub_caches(self):
|
|
358
|
+
n = self._n_model_layers()
|
|
359
|
+
return self._cache[:n], self._cache[n:]
|
|
360
|
+
|
|
361
|
+
# -- hybrid (qwen3_5) speculative rollback primitives ------------------
|
|
362
|
+
# The recurrent (Gated-DeltaNet) layers can't be trimmed, but they reassign
|
|
363
|
+
# their state each step, so a snapshot is just a reference copy (no array data
|
|
364
|
+
# moved) and restore is instant. The attention layers trim natively. Together
|
|
365
|
+
# these let PLD roll a rejected draft back exactly on a "non-trimmable" cache.
|
|
366
|
+
|
|
367
|
+
def _snap_recurrent(self):
|
|
368
|
+
return [list(c.cache) for c in self._cache
|
|
369
|
+
if isinstance(c, cache_utils.ArraysCache)]
|
|
370
|
+
|
|
371
|
+
def _restore_recurrent(self, snap):
|
|
372
|
+
rec = [c for c in self._cache if isinstance(c, cache_utils.ArraysCache)]
|
|
373
|
+
for c, arrs in zip(rec, snap):
|
|
374
|
+
c.cache = list(arrs)
|
|
375
|
+
|
|
376
|
+
def _trim_kv(self, n):
|
|
377
|
+
kv = [c for c in self._cache if isinstance(c, cache_utils.KVCache)]
|
|
378
|
+
cache_utils.trim_prompt_cache(kv, n)
|
|
379
|
+
|
|
380
|
+
def _sync_to(self, target_ids: list) -> int:
|
|
381
|
+
"""Trim the cache down to the longest common prefix with target_ids.
|
|
382
|
+
|
|
383
|
+
Returns the number of leading tokens already resident in the cache
|
|
384
|
+
(i.e. how many tokens we get to skip prefilling).
|
|
385
|
+
"""
|
|
386
|
+
common = 0
|
|
387
|
+
for a, b in zip(self._cached_ids, target_ids):
|
|
388
|
+
if a != b:
|
|
389
|
+
break
|
|
390
|
+
common += 1
|
|
391
|
+
|
|
392
|
+
extra = len(self._cached_ids) - common
|
|
393
|
+
if extra > 0:
|
|
394
|
+
model_cache, draft_cache = self._sub_caches()
|
|
395
|
+
if cache_utils.can_trim_prompt_cache(model_cache):
|
|
396
|
+
cache_utils.trim_prompt_cache(model_cache, extra)
|
|
397
|
+
if draft_cache:
|
|
398
|
+
cache_utils.trim_prompt_cache(draft_cache, extra)
|
|
399
|
+
self._cached_ids = self._cached_ids[:common]
|
|
400
|
+
else:
|
|
401
|
+
# Not trimmable (hybrid) -> can't partially rewind in RAM, so rebuild.
|
|
402
|
+
# But the dominant divergence in agentic loops is *compaction*, which
|
|
403
|
+
# only rewrites later turns and leaves the stable system+tools prefix
|
|
404
|
+
# intact. If we persisted that prefix to disk (warm_prefix), reload it
|
|
405
|
+
# (lossless, ~0.3s) instead of re-prefilling its ~3k tokens (~9s).
|
|
406
|
+
self._reset_cache()
|
|
407
|
+
common = self._reload_warm_prefix(target_ids)
|
|
408
|
+
return common
|
|
409
|
+
|
|
410
|
+
def _reload_warm_prefix(self, target_ids: list) -> int:
|
|
411
|
+
"""If a disk checkpoint of the warm system prefix exists and target_ids still
|
|
412
|
+
begins with it, load it into the freshly-reset cache and return its length
|
|
413
|
+
(tokens we skip re-prefilling). Returns 0 if unavailable/inapplicable."""
|
|
414
|
+
wp = self._warm_prefix_ids
|
|
415
|
+
if not (wp and self.cache_dir and len(target_ids) >= len(wp)
|
|
416
|
+
and target_ids[: len(wp)] == wp):
|
|
417
|
+
return 0
|
|
418
|
+
path = self._ckpt_path(wp)
|
|
419
|
+
if not os.path.isfile(path):
|
|
420
|
+
return 0
|
|
421
|
+
try:
|
|
422
|
+
loaded = cache_utils.load_prompt_cache(path)
|
|
423
|
+
if len(loaded) != self._n_model_layers():
|
|
424
|
+
return 0
|
|
425
|
+
self._cache = loaded
|
|
426
|
+
self._cached_ids = list(wp)
|
|
427
|
+
self._set_cache_flags()
|
|
428
|
+
return len(wp)
|
|
429
|
+
except Exception:
|
|
430
|
+
self._reset_cache() # corrupt/incompatible -> clean rebuild
|
|
431
|
+
return 0
|
|
432
|
+
|
|
433
|
+
# -- one-deep cache quarantine (plan 041) -----------------------------
|
|
434
|
+
# A subagent explores in a SEPARATE small context so the main transcript's warm
|
|
435
|
+
# cache isn't destroyed by the churn (grep/read spelunking). push_cache stashes the
|
|
436
|
+
# live cache aside and hands the subagent a fresh empty one; pop_cache restores the
|
|
437
|
+
# main cache bit-identically. The stash lives in RAM by default (measured cheap: a
|
|
438
|
+
# 30k-token hybrid main cache ≈ 615 MB), but spills to a disk checkpoint when holding
|
|
439
|
+
# it resident alongside the subagent's own growing cache would crowd the Metal budget.
|
|
440
|
+
|
|
441
|
+
def _should_spill(self, ids: list) -> bool:
|
|
442
|
+
"""Whether to spill the pushed main cache to disk (vs holding it in RAM) while a
|
|
443
|
+
subagent runs. The fast path keeps it in RAM; we only spill when the main cache
|
|
444
|
+
is large enough that keeping it resident would leave too little headroom under
|
|
445
|
+
Apple's recommended working set for the subagent to prefill its own context.
|
|
446
|
+
Needs cache_dir (nowhere to spill), a measured per-token cost, and the live
|
|
447
|
+
Metal memory APIs — returns False (hold in RAM) if any is unavailable."""
|
|
448
|
+
if not self.cache_dir or not self.kv_bytes_per_token or not ids:
|
|
449
|
+
return False
|
|
450
|
+
main_bytes = len(ids) * self.kv_bytes_per_token
|
|
451
|
+
try:
|
|
452
|
+
budget = int(mx.device_info()["max_recommended_working_set_size"])
|
|
453
|
+
active = mx.get_active_memory()
|
|
454
|
+
except Exception: # noqa: BLE001 — memory probe unavailable -> keep it in RAM
|
|
455
|
+
return False
|
|
456
|
+
# `active` already includes the resident model + the live main cache we're about
|
|
457
|
+
# to push. The free band under the (safety-scaled) working set is what the
|
|
458
|
+
# subagent gets to grow its own cache into. If that band is already tighter than
|
|
459
|
+
# the main cache we'd be holding aside, reclaim the main cache to disk.
|
|
460
|
+
free = budget * 0.90 - active
|
|
461
|
+
return free < main_bytes
|
|
462
|
+
|
|
463
|
+
def push_cache(self):
|
|
464
|
+
"""Depth-1 cache quarantine: stash the live (cache, cached_ids, flags) and start
|
|
465
|
+
a fresh empty cache so a subagent can run isolated. pop_cache restores it. Raises
|
|
466
|
+
if a cache is already pushed — subagents can't nest, and depth-1 keeps the
|
|
467
|
+
lifecycle trivially auditable. Spills the stashed cache to disk when RAM is tight
|
|
468
|
+
(see _should_spill), reclaiming it on pop."""
|
|
469
|
+
if self._cache_stack:
|
|
470
|
+
raise RuntimeError("push_cache: cache stack is depth-1 only (no nesting)")
|
|
471
|
+
frame = {
|
|
472
|
+
"cached_ids": self._cached_ids,
|
|
473
|
+
"trimmable": self._trimmable,
|
|
474
|
+
"pld_hybrid": self._pld_hybrid,
|
|
475
|
+
"warm_prefix_ids": self._warm_prefix_ids,
|
|
476
|
+
"cache": self._cache,
|
|
477
|
+
"spill_path": None,
|
|
478
|
+
}
|
|
479
|
+
if self._should_spill(self._cached_ids):
|
|
480
|
+
path = self._ckpt_path(self._cached_ids, tag="push")
|
|
481
|
+
try:
|
|
482
|
+
os.makedirs(self.cache_dir, exist_ok=True)
|
|
483
|
+
cache_utils.save_prompt_cache(path, self._cache)
|
|
484
|
+
frame["spill_path"] = path
|
|
485
|
+
frame["cache"] = None # drop the RAM reference; reclaimed on pop
|
|
486
|
+
except Exception: # noqa: BLE001 — disk full/read-only -> just hold in RAM
|
|
487
|
+
pass
|
|
488
|
+
self._cache_stack.append(frame)
|
|
489
|
+
self._reset_cache()
|
|
490
|
+
mx.clear_cache() # release the freed buffers (esp. after a spill drop)
|
|
491
|
+
|
|
492
|
+
def pop_cache(self):
|
|
493
|
+
"""Restore the cache stashed by push_cache, exactly. After this the main
|
|
494
|
+
session's cache + _cached_ids are bit-identical to before the push, so its
|
|
495
|
+
next turn re-syncs against a fully warm prefix (no re-prefill). Raises if
|
|
496
|
+
nothing was pushed."""
|
|
497
|
+
if not self._cache_stack:
|
|
498
|
+
raise RuntimeError("pop_cache: no pushed cache to restore")
|
|
499
|
+
frame = self._cache_stack.pop()
|
|
500
|
+
spill = frame.get("spill_path")
|
|
501
|
+
if spill:
|
|
502
|
+
try:
|
|
503
|
+
self._cache = cache_utils.load_prompt_cache(spill)
|
|
504
|
+
except Exception:
|
|
505
|
+
# The spilled checkpoint is missing/corrupt (spills only happen under
|
|
506
|
+
# Metal memory pressure, so this is narrow). Degrade to a clean re-prefill
|
|
507
|
+
# rather than propagate: pop_cache must never abort the parent — the
|
|
508
|
+
# invariant is that a stuck sub-agent can't corrupt it. _reset_cache clears
|
|
509
|
+
# _cached_ids, so the next turn re-syncs from empty and warms the prefix.
|
|
510
|
+
log.warning("pop_cache: spilled checkpoint %s unreadable; "
|
|
511
|
+
"re-prefilling the parent from scratch", spill)
|
|
512
|
+
self._reset_cache()
|
|
513
|
+
try:
|
|
514
|
+
os.remove(spill)
|
|
515
|
+
except OSError:
|
|
516
|
+
pass
|
|
517
|
+
mx.clear_cache()
|
|
518
|
+
return
|
|
519
|
+
try:
|
|
520
|
+
os.remove(spill)
|
|
521
|
+
except OSError:
|
|
522
|
+
pass
|
|
523
|
+
else:
|
|
524
|
+
self._cache = frame["cache"]
|
|
525
|
+
self._cached_ids = frame["cached_ids"]
|
|
526
|
+
self._trimmable = frame["trimmable"]
|
|
527
|
+
self._pld_hybrid = frame["pld_hybrid"]
|
|
528
|
+
self._warm_prefix_ids = frame["warm_prefix_ids"]
|
|
529
|
+
mx.clear_cache()
|
|
530
|
+
|
|
531
|
+
# -- generation -------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
def _prefill(self, ids: list, should_stop=None, chunk: int = 256,
|
|
534
|
+
on_progress=None) -> int:
|
|
535
|
+
"""Feed token ids through the model into the live cache in chunks, checking
|
|
536
|
+
should_stop between chunks. Returns the count actually fed (< len(ids) when
|
|
537
|
+
interrupted). This is what makes a large re-prefill abortable: MLX runs one
|
|
538
|
+
chunk at a time and we get a chance to bail between them.
|
|
539
|
+
|
|
540
|
+
on_progress(done, total), if given, fires once per chunk with the tokens fed
|
|
541
|
+
so far (monotonic, ending at `total` on a clean pass) so a caller can show a
|
|
542
|
+
live progress %. It is **only** called when not None — the hot loop is
|
|
543
|
+
byte-identical to a no-callback run, which matters because this feeds the
|
|
544
|
+
non-trimmable cache."""
|
|
545
|
+
mc = self._cache
|
|
546
|
+
n = len(ids)
|
|
547
|
+
i = 0
|
|
548
|
+
while i < n:
|
|
549
|
+
if should_stop and should_stop():
|
|
550
|
+
break
|
|
551
|
+
step = min(chunk, n - i)
|
|
552
|
+
self.model(mx.array(ids[i : i + step], dtype=mx.uint32)[None], cache=mc)
|
|
553
|
+
mx.eval([c.state for c in mc])
|
|
554
|
+
i += step
|
|
555
|
+
if on_progress:
|
|
556
|
+
on_progress(i, n)
|
|
557
|
+
return i
|
|
558
|
+
|
|
559
|
+
def generate(
|
|
560
|
+
self,
|
|
561
|
+
prompt_ids: list,
|
|
562
|
+
max_tokens: int = 2048,
|
|
563
|
+
on_token: Optional[Callable[[str], None]] = None,
|
|
564
|
+
stop_texts: Optional[list] = None,
|
|
565
|
+
should_stop: Optional[Callable[[], bool]] = None,
|
|
566
|
+
on_prefill: Optional[Callable[[int, int], None]] = None,
|
|
567
|
+
on_prefill_progress: Optional[Callable[[int, int], None]] = None,
|
|
568
|
+
stop_condition: Optional[Callable[[str, int], bool]] = None,
|
|
569
|
+
):
|
|
570
|
+
"""Generate a completion for the already-templated prompt_ids.
|
|
571
|
+
|
|
572
|
+
Uses the persistent prefix cache: only prompt_ids[common:] is prefilled.
|
|
573
|
+
Streams decoded text to on_token. Returns (text, GenStats). on_prefill(new,
|
|
574
|
+
cached) fires once the prefill size is known, before the (potentially slow)
|
|
575
|
+
prefill runs, so the caller can show honest status. on_prefill_progress(done,
|
|
576
|
+
total) fires once per prefill chunk so the caller can show an advancing % during
|
|
577
|
+
a big re-prefill; both prefill hooks are optional and pure instrumentation.
|
|
578
|
+
|
|
579
|
+
stop_condition(text_so_far, n_generated) -> bool is an OPTIONAL caller predicate
|
|
580
|
+
checked after each decoded token (composing with should_stop); when it returns
|
|
581
|
+
True generation halts and stats.stop_condition_fired is set, so the caller can
|
|
582
|
+
distinguish a deliberate early stop from an EOS/max_tokens finish. This is the
|
|
583
|
+
soft think-cap hook (plan 039): run_turn uses it to stop a ballooning <think>
|
|
584
|
+
run and force-close the block. None => byte-identical to a plain generate call.
|
|
585
|
+
"""
|
|
586
|
+
# Fairness / measurement knob (CHAD_NO_PREFIX_CACHE): drop the persistent prefix
|
|
587
|
+
# cache before every turn so each step full-prefills from scratch. This forfeits
|
|
588
|
+
# chad's core prefill win on purpose — the apples-to-apples TTFT baseline for
|
|
589
|
+
# head-to-head harness benchmarks (cache OFF on both sides). Off in normal use;
|
|
590
|
+
# matches the CHAD_NO_* opt-out family.
|
|
591
|
+
if config.flag("CHAD_NO_PREFIX_CACHE"):
|
|
592
|
+
self._reset_cache()
|
|
593
|
+
|
|
594
|
+
# Prompt-lookup decoding path: needs no draft model, greedy decoding (exact),
|
|
595
|
+
# an unquantized cache, and a trimmable cache (cheap rollback). A qwen3_5-style
|
|
596
|
+
# hybrid (Ornith) CAN also roll back, via recurrent-snapshot + KV-trim + re-feed
|
|
597
|
+
# (self._pld_hybrid), but the re-feed makes it ~2x slower on realistic agentic
|
|
598
|
+
# generation, so it's behind enable_pld_hybrid (opt-in, off by default). PLD
|
|
599
|
+
# shines on trimmable models doing edit-heavy work that re-quotes read files.
|
|
600
|
+
if (self.draft is None and self.prompt_lookup and self.temp == 0.0
|
|
601
|
+
and not self.kv_bits
|
|
602
|
+
and (self._trimmable or (self._pld_hybrid and self.enable_pld_hybrid))):
|
|
603
|
+
return self._generate_prompt_lookup(prompt_ids, max_tokens, on_token,
|
|
604
|
+
stop_texts, should_stop, on_prefill,
|
|
605
|
+
on_prefill_progress, stop_condition)
|
|
606
|
+
|
|
607
|
+
common = self._sync_to(prompt_ids)
|
|
608
|
+
suffix = prompt_ids[common:]
|
|
609
|
+
stats = GenStats(
|
|
610
|
+
prompt_tokens=len(suffix),
|
|
611
|
+
cached_tokens=common,
|
|
612
|
+
)
|
|
613
|
+
if not suffix:
|
|
614
|
+
# Nothing new to prefill (degenerate: the prompt is fully cached, e.g. an
|
|
615
|
+
# identical prompt regenerated — never in normal append-only turns). We
|
|
616
|
+
# still need one token to condition on, so we re-feed the last token. But
|
|
617
|
+
# the live KV cache already holds it, so we must drop it from the cache in
|
|
618
|
+
# lockstep, or the cache ends up one token LONGER than _cached_ids records
|
|
619
|
+
# — an off-by-one that desyncs the next turn's trim math and silently
|
|
620
|
+
# corrupts generation.
|
|
621
|
+
if self._trimmable:
|
|
622
|
+
# Mirror the PLD path: pop the last token off the live cache so the
|
|
623
|
+
# re-feed below lands the cache back at exactly prompt_ids.
|
|
624
|
+
cache_utils.trim_prompt_cache(self._cache, 1)
|
|
625
|
+
suffix = prompt_ids[-1:]
|
|
626
|
+
self._cached_ids = self._cached_ids[:-1]
|
|
627
|
+
stats.prompt_tokens = 1
|
|
628
|
+
stats.cached_tokens = common - 1
|
|
629
|
+
else:
|
|
630
|
+
# Non-trimmable hybrid (e.g. Ornith): we cannot pop a single token off
|
|
631
|
+
# the recurrent state, so trimming is invalid and re-feeding would
|
|
632
|
+
# duplicate the last token in the cache. This degenerate case is rare,
|
|
633
|
+
# so take the safe path: rebuild from scratch and full re-prefill.
|
|
634
|
+
# Slower but correct — a desynced non-trimmable cache silently corrupts
|
|
635
|
+
# every later turn, which is far worse than one extra re-prefill here.
|
|
636
|
+
self._reset_cache()
|
|
637
|
+
common = 0
|
|
638
|
+
suffix = list(prompt_ids)
|
|
639
|
+
stats.prompt_tokens = len(suffix)
|
|
640
|
+
stats.cached_tokens = 0
|
|
641
|
+
if on_prefill:
|
|
642
|
+
on_prefill(stats.prompt_tokens, stats.cached_tokens)
|
|
643
|
+
|
|
644
|
+
kwargs = dict(
|
|
645
|
+
max_tokens=max_tokens,
|
|
646
|
+
sampler=make_sampler(temp=self.temp),
|
|
647
|
+
prompt_cache=self._cache,
|
|
648
|
+
)
|
|
649
|
+
if self.kv_bits:
|
|
650
|
+
kwargs["kv_bits"] = self.kv_bits
|
|
651
|
+
if self.draft is not None:
|
|
652
|
+
kwargs["draft_model"] = self.draft
|
|
653
|
+
kwargs["num_draft_tokens"] = self.num_draft_tokens
|
|
654
|
+
|
|
655
|
+
t0 = time.time()
|
|
656
|
+
# Interruptible prefill (single-model, unquantized cache): feed everything
|
|
657
|
+
# but the last token ourselves so should_stop is honored between chunks.
|
|
658
|
+
# stream_generate then only has to prefill the final token before decoding.
|
|
659
|
+
gen_prompt = suffix
|
|
660
|
+
if self.draft is None and not self.kv_bits and len(suffix) > 1:
|
|
661
|
+
fed = self._prefill(suffix[:-1], should_stop, on_progress=on_prefill_progress)
|
|
662
|
+
if fed < len(suffix) - 1: # interrupted mid-prefill
|
|
663
|
+
self._cached_ids = prompt_ids[: common + fed]
|
|
664
|
+
stats.prefill_s = time.time() - t0
|
|
665
|
+
return "", stats
|
|
666
|
+
gen_prompt = suffix[-1:]
|
|
667
|
+
|
|
668
|
+
text = ""
|
|
669
|
+
gen_ids = []
|
|
670
|
+
first_token_at = None
|
|
671
|
+
for resp in stream_generate(self.model, self.tok, mx.array(gen_prompt), **kwargs):
|
|
672
|
+
if first_token_at is None:
|
|
673
|
+
first_token_at = time.time()
|
|
674
|
+
stats.prefill_s = first_token_at - t0
|
|
675
|
+
text += resp.text
|
|
676
|
+
gen_ids.append(resp.token)
|
|
677
|
+
if on_token:
|
|
678
|
+
on_token(resp.text)
|
|
679
|
+
if stop_texts and any(s in text for s in stop_texts):
|
|
680
|
+
break
|
|
681
|
+
if should_stop and should_stop():
|
|
682
|
+
break
|
|
683
|
+
if stop_condition is not None and stop_condition(text, len(gen_ids)):
|
|
684
|
+
stats.stop_condition_fired = True
|
|
685
|
+
break
|
|
686
|
+
stats.gen_s = time.time() - (first_token_at or t0)
|
|
687
|
+
stats.generated_tokens = len(gen_ids)
|
|
688
|
+
|
|
689
|
+
# The cache now holds prefix + the tokens we generated.
|
|
690
|
+
self._cached_ids = prompt_ids + gen_ids
|
|
691
|
+
# Return MLX's freed-buffer pool to the OS. The live KV cache is held in
|
|
692
|
+
# self._cache (active memory, untouched); this only releases the transient
|
|
693
|
+
# prefill/decode scratch buffers that otherwise accumulate as cached memory
|
|
694
|
+
# turn-over-turn and show up as a steadily climbing RSS.
|
|
695
|
+
mx.clear_cache()
|
|
696
|
+
return text, stats
|
|
697
|
+
|
|
698
|
+
# -- prompt-lookup (n-gram) speculative decoding ----------------------
|
|
699
|
+
|
|
700
|
+
def _eos_ids(self) -> set:
|
|
701
|
+
ids = set()
|
|
702
|
+
eid = getattr(self.tok, "eos_token_id", None)
|
|
703
|
+
if eid is not None:
|
|
704
|
+
ids.add(int(eid))
|
|
705
|
+
for extra in getattr(self.tok, "eos_token_ids", None) or []:
|
|
706
|
+
ids.add(int(extra))
|
|
707
|
+
return ids
|
|
708
|
+
|
|
709
|
+
def _generate_prompt_lookup(self, prompt_ids, max_tokens, on_token, stop_texts,
|
|
710
|
+
should_stop=None, on_prefill=None,
|
|
711
|
+
on_prefill_progress=None, stop_condition=None):
|
|
712
|
+
"""Single-model speculative decoding using n-gram prompt lookup as the
|
|
713
|
+
drafter. Mirrors the prefix-cache contract of `generate`: only the new
|
|
714
|
+
suffix is prefilled, drafts are verified in one batched forward, accepted
|
|
715
|
+
runs are committed, and the cache is trimmed back on rejection."""
|
|
716
|
+
common = self._sync_to(prompt_ids)
|
|
717
|
+
suffix = prompt_ids[common:]
|
|
718
|
+
stats = GenStats(prompt_tokens=len(suffix), cached_tokens=common)
|
|
719
|
+
if on_prefill:
|
|
720
|
+
on_prefill(stats.prompt_tokens, stats.cached_tokens)
|
|
721
|
+
|
|
722
|
+
mc = self._cache # model-only cache (no draft model on this path)
|
|
723
|
+
eos = self._eos_ids()
|
|
724
|
+
prefill_step = 512
|
|
725
|
+
# On a hybrid cache we roll drafts back by snapshot/restore instead of trim.
|
|
726
|
+
hybrid = self._pld_hybrid and not self._trimmable
|
|
727
|
+
|
|
728
|
+
t0 = time.time()
|
|
729
|
+
|
|
730
|
+
def _prefill_head():
|
|
731
|
+
"""Prefill suffix[:-1] (all but the conditioning token) through the shared
|
|
732
|
+
chunked `_prefill`, which honors should_stop between chunks. Returns True on
|
|
733
|
+
a clean prefill, False if interrupted (caller returns an empty turn). One
|
|
734
|
+
helper for both branches below — previously this loop was inlined twice and
|
|
735
|
+
the hybrid copy silently dropped the should_stop check (un-abortable)."""
|
|
736
|
+
fed = self._prefill(suffix[:-1], should_stop, chunk=prefill_step,
|
|
737
|
+
on_progress=on_prefill_progress)
|
|
738
|
+
if fed < len(suffix) - 1: # interrupted mid-prefill
|
|
739
|
+
self._cached_ids = list(prompt_ids[: common + fed])
|
|
740
|
+
stats.prefill_s = time.time() - t0
|
|
741
|
+
return False
|
|
742
|
+
return True
|
|
743
|
+
|
|
744
|
+
# Prefill everything but the last token (we need a token to condition on).
|
|
745
|
+
if not suffix:
|
|
746
|
+
if hybrid:
|
|
747
|
+
# Can't pop a single token off the recurrent state, so rebuild and
|
|
748
|
+
# re-prefill all-but-last. Rare: only fires when an identical prompt
|
|
749
|
+
# is regenerated (never in normal append-only agentic turns).
|
|
750
|
+
self._reset_cache()
|
|
751
|
+
mc = self._cache
|
|
752
|
+
common, suffix = 0, list(prompt_ids)
|
|
753
|
+
stats.prompt_tokens, stats.cached_tokens = len(suffix) - 1, 0
|
|
754
|
+
if not _prefill_head():
|
|
755
|
+
return "", stats
|
|
756
|
+
y_val = suffix[-1]
|
|
757
|
+
else:
|
|
758
|
+
# Degenerate: prompt fully cached. Re-feed the last token.
|
|
759
|
+
cache_utils.trim_prompt_cache(mc, 1)
|
|
760
|
+
y_val = prompt_ids[-1]
|
|
761
|
+
stats.prompt_tokens, stats.cached_tokens = 1, common - 1
|
|
762
|
+
else:
|
|
763
|
+
if not _prefill_head():
|
|
764
|
+
return "", stats
|
|
765
|
+
y_val = suffix[-1]
|
|
766
|
+
first_token_at = None
|
|
767
|
+
|
|
768
|
+
context = list(prompt_ids) # n-gram lookup window (prompt + generated)
|
|
769
|
+
out_ids = [] # tokens generated this turn (for text)
|
|
770
|
+
# fed_ids mirrors exactly what's resident in the KV cache (cache-type
|
|
771
|
+
# agnostic, since ArraysCache exposes no offset). The final pending token
|
|
772
|
+
# (y_val) is intentionally NOT fed, so it isn't included here.
|
|
773
|
+
fed_ids = list(prompt_ids[:-1])
|
|
774
|
+
detok = self.tok.detokenizer
|
|
775
|
+
detok.reset()
|
|
776
|
+
# Adaptive draft width: wide verify forwards are nearly free on a big
|
|
777
|
+
# bandwidth-bound model but cost real compute on a small one. Track a recent
|
|
778
|
+
# acceptance EMA and draft wide only while it's paying off, so PLD never
|
|
779
|
+
# meaningfully slows novel generation (low n-gram hit rate).
|
|
780
|
+
nd_max = self.pld_num_draft
|
|
781
|
+
acc_ema = 0.5
|
|
782
|
+
# Hybrid backoff: on a recurrent cache every rejected draft costs an extra
|
|
783
|
+
# re-feed forward, so drafting only pays inside genuinely high-accept (quote)
|
|
784
|
+
# spans. Stay at nd=0 (zero-tax standard decode) when cold, probe cheaply to
|
|
785
|
+
# detect entering a quote span, and latch wide once a draft is mostly accepted.
|
|
786
|
+
hot, step_i = 0, 0
|
|
787
|
+
PROBE_EVERY, PROBE_ND, HOT_STEPS = 10, 4, 12
|
|
788
|
+
|
|
789
|
+
while len(out_ids) < max_tokens:
|
|
790
|
+
if should_stop and should_stop():
|
|
791
|
+
break
|
|
792
|
+
if hybrid:
|
|
793
|
+
if hot > 0:
|
|
794
|
+
nd = nd_max # latched in a quote span: draft wide
|
|
795
|
+
elif step_i % PROBE_EVERY == 0:
|
|
796
|
+
nd = PROBE_ND # cheap periodic probe for a quote span
|
|
797
|
+
else:
|
|
798
|
+
nd = 0 # cold: standard decode, zero re-feed tax
|
|
799
|
+
else:
|
|
800
|
+
nd = nd_max if acc_ema >= 0.25 else 2
|
|
801
|
+
step_i += 1
|
|
802
|
+
draft = prompt_lookup_draft(context, nd, ngram_max=self.pld_ngram)
|
|
803
|
+
k = len(draft)
|
|
804
|
+
# Hybrid: snapshot the recurrent state before the verify forward so a
|
|
805
|
+
# partial accept can roll back exactly (the snapshot is a free reference
|
|
806
|
+
# copy; restore lands us at the pre-forward point to re-feed from).
|
|
807
|
+
rec_snap = self._snap_recurrent() if hybrid else None
|
|
808
|
+
y = mx.array([y_val] + draft, dtype=mx.uint32)
|
|
809
|
+
logits = self.model(y[None], cache=mc)
|
|
810
|
+
toks = mx.argmax(logits[0, -(k + 1):, :], axis=-1)
|
|
811
|
+
mx.eval(toks)
|
|
812
|
+
if first_token_at is None:
|
|
813
|
+
first_token_at = time.time()
|
|
814
|
+
stats.prefill_s = first_token_at - t0
|
|
815
|
+
toks = [int(t) for t in toks.tolist()]
|
|
816
|
+
stats.forwards += 1
|
|
817
|
+
stats.draft_proposed += k
|
|
818
|
+
|
|
819
|
+
# Accept the longest prefix of the draft the model agrees with.
|
|
820
|
+
n_acc = 0
|
|
821
|
+
while n_acc < k and toks[n_acc] == draft[n_acc]:
|
|
822
|
+
n_acc += 1
|
|
823
|
+
stats.draft_accepted += n_acc
|
|
824
|
+
if k:
|
|
825
|
+
acc_ema = 0.85 * acc_ema + 0.15 * (n_acc / k)
|
|
826
|
+
if hybrid and k:
|
|
827
|
+
# Latch wide after a mostly-accepted draft; decay back toward probing.
|
|
828
|
+
hot = HOT_STEPS if n_acc >= max(1, int(0.6 * k)) else max(0, hot - 1)
|
|
829
|
+
|
|
830
|
+
# Roll the cache back over the rejected drafts. What stays resident:
|
|
831
|
+
# the previously-pending y_val plus the n_acc accepted draft tokens.
|
|
832
|
+
# (Full accept needs no rollback either way.)
|
|
833
|
+
if k - n_acc > 0:
|
|
834
|
+
if hybrid:
|
|
835
|
+
# Restore the recurrent state to the pre-forward point, trim the
|
|
836
|
+
# attention KV all the way back too, then re-feed just the kept
|
|
837
|
+
# prefix so BOTH advance together to exactly y_val+draft[:n_acc].
|
|
838
|
+
self._restore_recurrent(rec_snap)
|
|
839
|
+
self._trim_kv(k + 1)
|
|
840
|
+
refeed = mx.array([y_val] + draft[:n_acc], dtype=mx.uint32)
|
|
841
|
+
self.model(refeed[None], cache=mc)
|
|
842
|
+
mx.eval([c.state for c in mc])
|
|
843
|
+
else:
|
|
844
|
+
cache_utils.trim_prompt_cache(mc, k - n_acc)
|
|
845
|
+
fed_ids.append(y_val)
|
|
846
|
+
fed_ids.extend(draft[:n_acc])
|
|
847
|
+
|
|
848
|
+
committed = draft[:n_acc] + [toks[n_acc]] # accepted run + 1 bonus token
|
|
849
|
+
stop = False
|
|
850
|
+
for tid in committed:
|
|
851
|
+
if tid in eos or len(out_ids) >= max_tokens:
|
|
852
|
+
stop = True
|
|
853
|
+
break
|
|
854
|
+
out_ids.append(tid)
|
|
855
|
+
context.append(tid)
|
|
856
|
+
detok.add_token(tid)
|
|
857
|
+
if on_token:
|
|
858
|
+
seg = detok.last_segment
|
|
859
|
+
if seg:
|
|
860
|
+
on_token(seg)
|
|
861
|
+
# Soft think-cap (plan 039): honor the caller's stop_condition on this path
|
|
862
|
+
# too. Checked on the committed run's text/count; byte-identical when None.
|
|
863
|
+
if not stop and stop_condition is not None \
|
|
864
|
+
and stop_condition(detok.text, len(out_ids)):
|
|
865
|
+
stats.stop_condition_fired = True
|
|
866
|
+
stop = True
|
|
867
|
+
y_val = toks[n_acc] # bonus token becomes next pending (unfed) token
|
|
868
|
+
if stop:
|
|
869
|
+
break
|
|
870
|
+
if stop_texts and any(s in detok.text for s in stop_texts):
|
|
871
|
+
break
|
|
872
|
+
|
|
873
|
+
detok.finalize()
|
|
874
|
+
if on_token and detok.last_segment:
|
|
875
|
+
on_token(detok.last_segment)
|
|
876
|
+
stats.gen_s = time.time() - (first_token_at or t0)
|
|
877
|
+
stats.generated_tokens = len(out_ids)
|
|
878
|
+
|
|
879
|
+
# fed_ids is exactly what's resident in the KV cache, so it's the correct
|
|
880
|
+
# prefix to diff against next turn.
|
|
881
|
+
self._cached_ids = fed_ids
|
|
882
|
+
mx.clear_cache() # release transient scratch buffers back to the OS
|
|
883
|
+
return detok.text, stats
|