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/base_engine.py ADDED
@@ -0,0 +1,109 @@
1
+ """The engine seam: `GenStats` + the `BaseEngine` protocol (plan 046).
2
+
3
+ `BaseEngine` is the *de facto* interface `Agent` already drives on `Engine` — extracted
4
+ verbatim into a `typing.Protocol` so a second backend can implement it without touching
5
+ the agent loop, and so mypy enforces the boundary. It **documents the seam that exists**;
6
+ it does not redesign it. `Engine` (the MLX crown jewel in `engine.py`) satisfies it
7
+ unchanged, and `OpenAIEngine` (`openai_engine.py`) is the spike adapter that proves a
8
+ different backend can plug into the same slot.
9
+
10
+ Why a Protocol (not an ABC): zero runtime cost, structural (no base-class edit to
11
+ `Engine`), and it keeps `GenStats` — a pure dataclass with no MLX dependency — importable
12
+ by a backend that never loads mlx. `GenStats` lives here (not `engine.py`) precisely so
13
+ the OpenAI adapter can build one without dragging in `mlx.core`.
14
+ """
15
+
16
+ from dataclasses import dataclass
17
+ from typing import Any, Callable, Optional, Protocol, runtime_checkable
18
+
19
+
20
+ @dataclass
21
+ class GenStats:
22
+ prompt_tokens: int = 0 # tokens actually prefilled this turn
23
+ cached_tokens: int = 0 # tokens served from the prefix cache
24
+ generated_tokens: int = 0
25
+ prefill_s: float = 0.0
26
+ gen_s: float = 0.0
27
+ forwards: int = 0 # model forward passes (PLD: < generated_tokens)
28
+ draft_proposed: int = 0 # PLD: n-gram tokens proposed
29
+ draft_accepted: int = 0 # PLD: n-gram tokens accepted
30
+ stop_condition_fired: bool = False # generation halted by the caller's stop_condition
31
+ # (plan 039 soft think-cap), not by EOS / max_tokens
32
+ approximate: bool = False # stats are best-effort, not exact (plan 046): an
33
+ # OpenAI-style backend can't report cached_tokens /
34
+ # per-forward accounting, so it sets this and callers
35
+ # know the throughput/prefill numbers are estimates.
36
+
37
+ @property
38
+ def tok_per_s(self) -> float:
39
+ return self.generated_tokens / self.gen_s if self.gen_s > 0 else 0.0
40
+
41
+ @property
42
+ def accept_rate(self) -> float:
43
+ return self.draft_accepted / self.draft_proposed if self.draft_proposed else 0.0
44
+
45
+ @property
46
+ def tokens_per_forward(self) -> float:
47
+ return self.generated_tokens / self.forwards if self.forwards else 0.0
48
+
49
+
50
+ @runtime_checkable
51
+ class BaseEngine(Protocol):
52
+ """The interface `Agent` drives on an engine (plan 046). Every member here is one
53
+ `Agent` (or the TUI/REPL) already touches on `Engine` today — nothing aspirational.
54
+
55
+ The two backends that satisfy it:
56
+ - `engine.Engine` — MLX, in-process, persistent prefix KV cache (the default).
57
+ - `openai_engine.OpenAIEngine` — an OpenAI-compatible `/v1/chat/completions` client.
58
+
59
+ Data members (`tok`, `model_id`, `effective_ctx`, `cache_dir`, `_cached_ids`) are read
60
+ by the agent loop; the methods are the calls it makes each turn. The warm-prefix /
61
+ cache-quarantine members are honored by the MLX engine and no-op'd by a stateless
62
+ backend (see `OpenAIEngine`), which is exactly why they're part of the documented seam
63
+ rather than hidden inside `Engine`.
64
+ """
65
+
66
+ # --- data members the agent loop reads -------------------------------
67
+ tok: Any # tokenizer: apply_chat_template / decode / detokenizer
68
+ model_id: str
69
+ effective_ctx: int # usable context window (tokens)
70
+ cache_dir: Optional[str] # on-disk KV checkpoint dir; None disables warm-start
71
+ _cached_ids: list # tokens currently resident in the prefix cache
72
+
73
+ # --- generation ------------------------------------------------------
74
+ def generate(
75
+ self,
76
+ prompt_ids: list,
77
+ max_tokens: int = 2048,
78
+ on_token: Optional[Callable[[str], None]] = None,
79
+ stop_texts: Optional[list] = None,
80
+ should_stop: Optional[Callable[[], bool]] = None,
81
+ on_prefill: Optional[Callable[[int, int], None]] = None,
82
+ on_prefill_progress: Optional[Callable[[int, int], None]] = None,
83
+ stop_condition: Optional[Callable[[str, int], bool]] = None,
84
+ ) -> tuple[str, GenStats]:
85
+ """Generate a completion for the already-templated `prompt_ids`, streaming decoded
86
+ text to `on_token`. Returns `(text, GenStats)`. Signature mirrors `Engine.generate`
87
+ exactly so `Engine` satisfies this protocol unchanged."""
88
+ ...
89
+
90
+ # --- cache lifecycle -------------------------------------------------
91
+ def reset(self) -> None:
92
+ """Drop any conversational state / prefix cache (public alias of the MLX engine's
93
+ `_reset_cache`). Called on `/reset` and by the governor's fresh-turn relaunch."""
94
+ ...
95
+
96
+ def warm_prefix(self, prefix_ids: list, should_stop: Optional[Callable[[], bool]] = None
97
+ ) -> tuple[str, int]:
98
+ """Warm-start the stable system+tools prefix from disk (MLX) or no-op ('skip', 0)
99
+ on a stateless backend. Returns (status, n_tokens)."""
100
+ ...
101
+
102
+ def push_cache(self) -> None:
103
+ """Quarantine the live cache so a sub-agent can run isolated (MLX); no-op on a
104
+ stateless backend, which has no cache to protect."""
105
+ ...
106
+
107
+ def pop_cache(self) -> None:
108
+ """Restore the cache stashed by `push_cache` (MLX); no-op on a stateless backend."""
109
+ ...
chad/bench.py ADDED
@@ -0,0 +1,243 @@
1
+ """Throughput benchmark — the numbers behind docs/benchmarks.md, reproducible locally.
2
+
3
+ Run it: `uv run chad-bench` (downloads the same model `chad` uses on first run).
4
+
5
+ It drives the *real* `Engine` on the *real* model and reports the three numbers that
6
+ actually decide whether a local agent feels responsive:
7
+
8
+ 1. Prefill (cold) — tok/s the model reads a fresh prompt at. The bill in a naive loop.
9
+ 2. Decode — tok/s it writes new tokens at. Memory-bandwidth bound, ~constant.
10
+ 3. Warm step — the agentic-loop number: how few tokens a *follow-up* turn has to
11
+ prefill once the persistent prefix cache is warm. This is the whole
12
+ point — a real turn pays ~0.1 s of prefill, not the cold-prefill time.
13
+
14
+ Everything here uses only public code and the public model, so the headline numbers in
15
+ the docs are something you can reproduce on your own Mac, not pass-rates from a private
16
+ eval suite.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import sys
22
+
23
+ # Reuse the exact model selection chad itself uses (RAM-aware default, local-dir-preferred,
24
+ # HF fallback) so the benchmark measures the model you'd actually run.
25
+ from .cli import _ensure_model, _pick_model
26
+ from .engine import Engine
27
+
28
+ # A chunk of plausible code/transcript text. We tile it to the requested token budget so
29
+ # the prefill measurement runs over realistic content (not a single repeated token, which
30
+ # the n-gram path could short-circuit). Decode speed is content-independent (bandwidth).
31
+ _FILLER = '''
32
+ def process(records, *, limit=None, strict=False):
33
+ """Normalize and validate a batch of records, returning (ok, errors)."""
34
+ ok, errors = [], []
35
+ for i, rec in enumerate(records):
36
+ if limit is not None and len(ok) >= limit:
37
+ break
38
+ try:
39
+ name = rec["name"].strip()
40
+ value = int(rec.get("value", 0))
41
+ except (KeyError, ValueError, AttributeError) as exc:
42
+ errors.append((i, str(exc)))
43
+ if strict:
44
+ raise
45
+ continue
46
+ ok.append({"name": name, "value": value, "index": i})
47
+ return ok, errors
48
+ '''
49
+
50
+
51
+ def _build_prompt(tok, target_tokens: int) -> list:
52
+ """Tile filler text and encode to roughly `target_tokens` token ids."""
53
+ chunk_ids = tok.encode(_FILLER)
54
+ if not chunk_ids:
55
+ chunk_ids = tok.encode("the quick brown fox jumps over the lazy dog ")
56
+ reps = max(1, target_tokens // len(chunk_ids) + 1)
57
+ ids = (chunk_ids * reps)[:target_tokens]
58
+ return ids
59
+
60
+
61
+ def _encode_suffix(tok, text: str) -> list:
62
+ """Encode a short follow-up turn without a leading BOS (it's mid-sequence)."""
63
+ try:
64
+ return tok.encode(text, add_special_tokens=False)
65
+ except TypeError:
66
+ ids = tok.encode(text)
67
+ # Drop a leading BOS if the tokenizer added one.
68
+ bos = getattr(tok, "bos_token_id", None)
69
+ if bos is not None and ids and ids[0] == bos:
70
+ ids = ids[1:]
71
+ return ids
72
+
73
+
74
+ def _render(tok, messages, thinking=True) -> list:
75
+ """Render a transcript exactly as Agent._render does (chat template + tool schemas
76
+ + generation prompt), so the benchmark exercises the same token stream the real
77
+ agentic loop feeds the engine."""
78
+ from .tools import active_schemas
79
+ return tok.apply_chat_template(
80
+ messages, tools=active_schemas(), add_generation_prompt=True,
81
+ enable_thinking=thinking)
82
+
83
+
84
+ def _agentic(model_id: str, why: str, context_tokens: int, apply_fix: bool):
85
+ """Drive the REAL engine through a scripted agentic session that forces a
86
+ truncated (token-cap) turn at large context, and measure the prefill the FOLLOWING
87
+ step pays. This is the cache-miss the logs show: a turn cut off mid-`<think>` leaves
88
+ the stored assistant turn unable to re-tokenize into a prefix of the non-trimmable
89
+ KV cache, so the next step re-prefills the whole transcript (0 cached).
90
+
91
+ With `apply_fix`, the dangling think block is closed before the turn is stored
92
+ (chad.agent.close_unclosed_think) — the same normalization run_turn now applies — so
93
+ the cached tokens stay a prefix and the next step prefills only a handful of tokens.
94
+
95
+ Returns (miss_prefill_tokens, miss_prefill_s, cached_tokens)."""
96
+ from .agent import build_system_prompt, close_unclosed_think
97
+ from .engine import Engine
98
+
99
+ eng = Engine(model_id=model_id, draft_id=None, cache_dir=None)
100
+ eng.load()
101
+ tok = eng.tok
102
+
103
+ # Seed a large, realistic context: system + a user task that pastes a big code blob,
104
+ # padded to roughly `context_tokens` so the re-prefill cost is at the scale the user
105
+ # hit (tens of thousands of tokens).
106
+ blob = (_FILLER * max(1, context_tokens // max(1, len(tok.encode(_FILLER)))))
107
+ messages = [
108
+ {"role": "system", "content": build_system_prompt()},
109
+ {"role": "user", "content": "Review this module and fix the validation bug.\n\n"
110
+ "```python\n" + blob + "\n```"},
111
+ ]
112
+
113
+ # The render up to the generation prompt: the template auto-opens `<think>\n` here, so
114
+ # the model's turn continues INSIDE the think block.
115
+ p1 = _render(tok, messages)
116
+ # A turn that hit the token cap MID-THINK: reasoning with no closing `</think>` (the
117
+ # `hit_cap=True` truncation the logs flag). Constructed deterministically rather than
118
+ # generated, so the benchmark reliably exercises the divergence (a short stochastic
119
+ # generation may close `</think>` within the cap and hide it).
120
+ # Ends on punctuation (not bare whitespace), as a real cap-hit truncation does — a
121
+ # token ending in a trailing space would merge under BPE on re-render and cost a
122
+ # 1-token tail divergence that no text-level close can avoid on a non-trimmable cache.
123
+ reasoning = (("Let me trace the validation path and reason about the boundary "
124
+ "conditions step by step. ") * 24).strip()
125
+ gen_ids = tok.encode(reasoning, add_special_tokens=False)
126
+ # Put the engine in the EXACT state generate() leaves after that truncated turn:
127
+ # the live KV cache holds prompt + the raw (unclosed) reasoning tokens.
128
+ cache_ids = list(p1) + gen_ids
129
+ eng._reset_cache()
130
+ eng._prefill(cache_ids)
131
+ eng._cached_ids = cache_ids
132
+
133
+ # Store the assistant turn the way run_turn does — with or without the fix — then
134
+ # append a canned tool result (as the loop would after a tool call).
135
+ stored = close_unclosed_think(reasoning, True) if apply_fix else reasoning
136
+ messages.append({"role": "assistant", "content": stored})
137
+ messages.append({"role": "tool", "name": "read",
138
+ "content": "def validate(x):\n return x is not None\n"})
139
+
140
+ # The FOLLOWING render. Its prefill is the cache miss we're measuring: with the fix
141
+ # off the unclosed think makes the stored turn re-tokenize so it is no longer a prefix
142
+ # of the cache -> non-trimmable reset -> full re-prefill. With the fix on the cache
143
+ # stays warm and only the appended tokens prefill.
144
+ p2 = _render(tok, messages)
145
+ _, s2 = eng.generate(p2, max_tokens=8)
146
+ return s2.prompt_tokens, s2.prefill_s, s2.cached_tokens
147
+
148
+
149
+ def _run_agentic(model_id: str, why: str, context_tokens: int) -> int:
150
+ w = 72
151
+ print(f"\nloading {model_id} [{why}] ...", flush=True)
152
+ # Fresh process-cold cache for each variant (separate Engine in _agentic).
153
+ off_new, off_s, off_cached = _agentic(model_id, why, context_tokens, apply_fix=False)
154
+ on_new, on_s, on_cached = _agentic(model_id, why, context_tokens, apply_fix=True)
155
+ ctx = on_new + on_cached # actual rendered transcript size at the measured step
156
+ print("=" * w)
157
+ print(f"agentic prefill — truncated-turn cache miss @ {ctx:,} ctx tokens")
158
+ print("=" * w)
159
+ print(f"{'':22}{'new prefill':>14}{'cached':>10}{'prefill s':>12}")
160
+ print("-" * w)
161
+ print(f"{'truncation, fix OFF':22}{off_new:>14,}{off_cached:>10,}{off_s:>12.2f}")
162
+ print(f"{'truncation, fix ON':22}{on_new:>14,}{on_cached:>10,}{on_s:>12.2f}")
163
+ print("-" * w)
164
+ saved = off_new - on_new
165
+ speedup = (off_s / on_s) if on_s else float("inf")
166
+ print(f"fix avoids re-prefilling {saved:,} tokens on the step after a truncation "
167
+ f"(~{off_s - on_s:.1f}s, {speedup:.0f}x faster prefill).")
168
+ print("=" * w + "\n")
169
+ return 0
170
+
171
+
172
+ def main(argv=None) -> int:
173
+ ap = argparse.ArgumentParser(
174
+ prog="chad-bench",
175
+ description="Measure chad's prefill / decode / warm-step throughput on this machine.",
176
+ )
177
+ ap.add_argument("--prefill-tokens", type=int, default=5000,
178
+ help="size of the cold prompt to prefill (default: 5000)")
179
+ ap.add_argument("--gen-tokens", type=int, default=128,
180
+ help="tokens to decode for the decode measurement (default: 128)")
181
+ ap.add_argument("--agentic", action="store_true",
182
+ help="run the agentic-session prefill benchmark: reproduce the "
183
+ "truncated-turn cache miss and the fix, measuring the re-prefill "
184
+ "the next step pays (with/without the think-close fix)")
185
+ ap.add_argument("--context-tokens", type=int, default=24000,
186
+ help="seeded context size for --agentic (default: 24000)")
187
+ args = ap.parse_args(argv)
188
+
189
+ if args.agentic:
190
+ model_id, why = _pick_model()
191
+ _ensure_model(model_id)
192
+ return _run_agentic(model_id, why, args.context_tokens)
193
+
194
+ model_id, why = _pick_model()
195
+ _ensure_model(model_id)
196
+
197
+ # cache_dir=None so the on-disk warm-prefix can't pre-load and skew the *cold* prefill
198
+ # number; this benchmark measures from a genuinely cold cache.
199
+ eng = Engine(model_id=model_id, draft_id=None, cache_dir=None)
200
+ sys.stderr.write(f"loading {model_id} [{why}] ...\n")
201
+ load_s = eng.load()
202
+
203
+ prompt_ids = _build_prompt(eng.tok, args.prefill_tokens)
204
+
205
+ # 1 + 2: cold prefill, then decode. One generate() call does both; GenStats splits the
206
+ # time into prefill_s (reading the prompt) and gen_s (writing new tokens).
207
+ _, stats = eng.generate(prompt_ids, max_tokens=args.gen_tokens)
208
+ prefill_tps = stats.prompt_tokens / stats.prefill_s if stats.prefill_s else 0.0
209
+ decode_tps = stats.tok_per_s
210
+
211
+ # 3: warm step. The cache now holds the whole prompt + what we just generated. A real
212
+ # agentic turn appends a small user/tool message and regenerates — a strict extension,
213
+ # so only the appended tokens prefill. Build that follow-up from the live cached ids.
214
+ suffix = _encode_suffix(eng.tok, "\n\nNow add a docstring to process() and rerun the tests.\n")
215
+ warm_prompt = list(eng._cached_ids) + suffix
216
+ _, wstats = eng.generate(warm_prompt, max_tokens=8)
217
+
218
+ w = 64
219
+ print("\n" + "=" * w)
220
+ print(f"chad throughput — {model_id}")
221
+ print(f" {why}")
222
+ print("=" * w)
223
+ print(f"model load {load_s:6.1f} s")
224
+ print("-" * w)
225
+ print(f"1. prefill (cold) {stats.prompt_tokens:6d} tok in {stats.prefill_s:6.2f} s"
226
+ f" -> {prefill_tps:6.0f} tok/s")
227
+ print(f"2. decode {stats.generated_tokens:6d} tok in {stats.gen_s:6.2f} s"
228
+ f" -> {decode_tps:6.1f} tok/s")
229
+ print(f"3. warm step {wstats.prompt_tokens:6d} new tok prefilled "
230
+ f"({wstats.cached_tokens} cached)")
231
+ print(f" {wstats.prefill_s:6.2f} s of prefill for the follow-up turn")
232
+ print("-" * w)
233
+ cold_est = (stats.prompt_tokens / prefill_tps) if prefill_tps else 0.0
234
+ print(f"the agentic-loop win: a follow-up turn prefills {wstats.prompt_tokens} tokens "
235
+ f"(~{wstats.prefill_s:.2f} s),")
236
+ print(f"not the full {wstats.cached_tokens + wstats.prompt_tokens} "
237
+ f"(~{cold_est:.1f} s) a cache-less backend would re-read every step.")
238
+ print("=" * w + "\n")
239
+ return 0
240
+
241
+
242
+ if __name__ == "__main__":
243
+ raise SystemExit(main())