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/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