chad-code 2.1.0__tar.gz → 2.2.0__tar.gz

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.
Files changed (109) hide show
  1. {chad_code-2.1.0 → chad_code-2.2.0}/PKG-INFO +27 -17
  2. {chad_code-2.1.0 → chad_code-2.2.0}/README.md +26 -16
  3. {chad_code-2.1.0 → chad_code-2.2.0}/pyproject.toml +1 -1
  4. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/__init__.py +1 -1
  5. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/agent.py +16 -7
  6. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/base_engine.py +3 -0
  7. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/cli.py +18 -22
  8. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/completion_engine.py +1 -0
  9. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/engine.py +34 -4
  10. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/mlx_dflash.py +98 -2
  11. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/mlx_fastpath.py +370 -10
  12. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/mlx_qmm_mma.py +150 -54
  13. chad_code-2.2.0/src/chad/prism_pack.py +253 -0
  14. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad_code.egg-info/PKG-INFO +27 -17
  15. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad_code.egg-info/SOURCES.txt +2 -0
  16. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_agent_e2e.py +33 -0
  17. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_cli.py +2 -2
  18. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_engine_dflash.py +140 -0
  19. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_mlx_qmm_mma.py +75 -14
  20. chad_code-2.2.0/tests/test_prism_pack.py +374 -0
  21. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_warm_prefix_tiers.py +23 -0
  22. {chad_code-2.1.0 → chad_code-2.2.0}/LICENSE +0 -0
  23. {chad_code-2.1.0 → chad_code-2.2.0}/setup.cfg +0 -0
  24. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/ambient.py +0 -0
  25. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/atif.py +0 -0
  26. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/bench.py +0 -0
  27. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/checkpoint.py +0 -0
  28. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/compaction.py +0 -0
  29. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/config.py +0 -0
  30. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/diag.py +0 -0
  31. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/guardrails.py +0 -0
  32. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/ignore.py +0 -0
  33. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/levers.py +0 -0
  34. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/mcp.py +0 -0
  35. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/mcp_oauth.py +0 -0
  36. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/mlx_qsdpa.py +0 -0
  37. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/parakeet/LICENSE +0 -0
  38. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/parakeet/__init__.py +0 -0
  39. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/parakeet/alignment.py +0 -0
  40. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/parakeet/attention.py +0 -0
  41. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/parakeet/audio.py +0 -0
  42. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/parakeet/cache.py +0 -0
  43. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/parakeet/conformer.py +0 -0
  44. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/parakeet/ctc.py +0 -0
  45. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/parakeet/parakeet.py +0 -0
  46. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/parakeet/rnnt.py +0 -0
  47. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/parakeet/tokenizer.py +0 -0
  48. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/parakeet/utils.py +0 -0
  49. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/prompt.py +0 -0
  50. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/prove.py +0 -0
  51. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/render.py +0 -0
  52. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/repomap.py +0 -0
  53. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/seatbelt.py +0 -0
  54. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/session.py +0 -0
  55. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/skills.py +0 -0
  56. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/speech.py +0 -0
  57. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/spill.py +0 -0
  58. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/syntaxgate.py +0 -0
  59. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/toolcall_parse.py +0 -0
  60. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/tools.py +0 -0
  61. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/tui.py +0 -0
  62. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad/validate.py +0 -0
  63. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad_code.egg-info/dependency_links.txt +0 -0
  64. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad_code.egg-info/entry_points.txt +0 -0
  65. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad_code.egg-info/requires.txt +0 -0
  66. {chad_code-2.1.0 → chad_code-2.2.0}/src/chad_code.egg-info/top_level.txt +0 -0
  67. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_agent.py +0 -0
  68. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_agent_guards.py +0 -0
  69. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_ambient.py +0 -0
  70. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_atif.py +0 -0
  71. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_bench.py +0 -0
  72. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_checkpoint.py +0 -0
  73. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_cli_modes.py +0 -0
  74. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_compaction.py +0 -0
  75. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_completion_engine.py +0 -0
  76. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_config.py +0 -0
  77. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_confirm_preview.py +0 -0
  78. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_edit.py +0 -0
  79. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_engine.py +0 -0
  80. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_engine_kvquant.py +0 -0
  81. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_engine_pld_hybrid.py +0 -0
  82. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_engine_pld_wide.py +0 -0
  83. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_feel_pack.py +0 -0
  84. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_gate.py +0 -0
  85. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_ignore.py +0 -0
  86. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_intent.py +0 -0
  87. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_levers.py +0 -0
  88. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_log_redaction.py +0 -0
  89. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_matrix_bench.py +0 -0
  90. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_mcp.py +0 -0
  91. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_mcp_oauth.py +0 -0
  92. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_mlx_fastpath.py +0 -0
  93. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_mlx_qsdpa.py +0 -0
  94. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_prove.py +0 -0
  95. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_render.py +0 -0
  96. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_repomap.py +0 -0
  97. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_repomap_polyglot.py +0 -0
  98. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_seatbelt.py +0 -0
  99. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_session.py +0 -0
  100. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_skills.py +0 -0
  101. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_speech.py +0 -0
  102. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_speech_tui.py +0 -0
  103. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_spill.py +0 -0
  104. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_syntaxgate.py +0 -0
  105. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_toolcall_dialect.py +0 -0
  106. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_toolcall_parse.py +0 -0
  107. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_tools.py +0 -0
  108. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_tui.py +0 -0
  109. {chad_code-2.1.0 → chad_code-2.2.0}/tests/test_validate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chad-code
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Claude-Code-style coding agent that runs entirely on a 24 GB Apple Silicon Mac (MLX, Qwen3.8-27B)
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/nathansutton/chad
@@ -59,13 +59,13 @@ uvx chad-code # runs anywhere; the command is still `chad`
59
59
  uvx chad-code prove # offline smoke test: 4 tiny fix-it tasks, verified, timed 🗿
60
60
  ```
61
61
 
62
- The first run asks, then downloads the model once (~13 GB) into the shared Hugging
62
+ The first run asks, then downloads the model once (~8 GB) into the shared Hugging
63
63
  Face cache. While it downloads, `cd` into a project and think of a scoped first ask:
64
64
  *"fix the failing test in `tests/test_x.py`"* lands, *"improve my codebase"* flails.
65
65
 
66
66
  chad targets 24 GB and nothing smaller. It runs below that and tells you it is doing so,
67
- but 13 GB of weights sit resident before a single token of context, so a 16 GB Mac gets a
68
- window too small to work in.
67
+ but 8 GB of weights and drafter plus a ~4 GB prefill transient sit resident before a single
68
+ token of context, so a 16 GB Mac gets a window too small to work in.
69
69
 
70
70
  The PyPI package is `chad-code`. Bare `chad` is an unrelated squatted package.
71
71
 
@@ -79,9 +79,9 @@ The PyPI package is `chad-code`. Bare `chad` is an unrelated squatted package.
79
79
  ## Same model, same Mac, stock engine
80
80
 
81
81
  What do you gain over pointing a generic local-model tool at the same weights?
82
- Qwen3.8-27B at the same `UD-Q3_K_XL` recipe (Unsloth's GGUF for
83
- llama.cpp, chad's MLX conversion of the same bit map), the same M4 Pro (24 GB), one engine
84
- resident at a time, each measured with its own benchmark on a 512-token prompt and a
82
+ Qwen3.8-27B at the `UD-Q3_K_XL` recipe (Unsloth's GGUF for llama.cpp, chad's MLX
83
+ conversion of the same bit map; chad runs it with `--model`), the same M4 Pro (24 GB), one
84
+ engine resident at a time, each measured with its own benchmark on a 512-token prompt and a
85
85
  128-token generation.
86
86
 
87
87
  | Engine | Prefill (512-tok prompt) | Decode (128 tok) | Speculative decoding |
@@ -90,11 +90,17 @@ resident at a time, each measured with its own benchmark on a 512-token prompt a
90
90
  | llama.cpp `llama-server` (build 10917), serial | 97 tok/s | 11.3 tok/s | off |
91
91
  | llama.cpp `llama-server` (build 10917) | 95 tok/s | 11.1 tok/s² | DFlash2 drafter (Q4_K_M GGUF) |
92
92
  | **chad**, serial (`CHAD_NO_DFLASH=1`) | 100 tok/s | 17.9 tok/s | off |
93
- | **chad**, default | 101 tok/s | **62.9 tok/s**¹ | DFlash2 block drafter |
93
+ | **chad** | 101 tok/s | **62.9 tok/s**¹ | DFlash2 block drafter |
94
94
 
95
95
  A 200-token function body takes roughly 18 seconds at 10.9 tok/s and 3 at 63. You wait for
96
96
  the first one and you talk to the second.
97
97
 
98
+ The weights chad ships by default are the same model in Prism ML's **ternary** build
99
+ (every projection Hadamard-rotated and stored at 2 bits): 7.2 GB resident instead of 12.3,
100
+ the same 64 tok/s drafted and 21 serial on this Mac, and the 5 GB it gives back is
101
+ context, roughly a **150k-token window** where the 3-bit landed near 56k. The like-for-like
102
+ table above stays on the 3-bit recipe because that is the quant llama.cpp has a GGUF of.
103
+
98
104
  Ollama does not get its own row: it is llama.cpp underneath, measured without speculative
99
105
  decoding, and on the same GGUF (0.32.15, Modelfile `FROM` only) it measures 96 tok/s
100
106
  prefill and the same **10.9** decode.
@@ -202,20 +208,24 @@ chad ships exactly one, downloaded once into the shared Hugging Face cache
202
208
 
203
209
  | Model | Quant | Footprint |
204
210
  |---|---|---|
205
- | [Qwen3.8-27B `UD-Q3_K_XL-DFlash2`](https://huggingface.co/nathansutton/Qwen3.8-27B-UD-Q3_K_XL-DFlash2-MLX) | 3-bit group-64 body, 5-bit `lm_head`, bundled 4-bit DFlash2 drafter | ~13 GB resident, 262k native context |
211
+ | [Qwen3.8-27B `Ternary-Bonsai-2-DFlash2`](https://huggingface.co/nathansutton/Qwen3.8-27B-Ternary-Bonsai-2-DFlash2-MLX) (default) | Prism ML's ternary build: Hadamard-rotated, 2-bit group-128, levels {−s, 0, +s}; bundled 4-bit DFlash2 drafter | ~8 GB resident, 262k native context, ~150k usable on 24 GB |
212
+ | [Qwen3.8-27B `UD-Q3_K_XL-DFlash2`](https://huggingface.co/nathansutton/Qwen3.8-27B-UD-Q3_K_XL-DFlash2-MLX) via `--model` | 3-bit group-64 body, 5-bit `lm_head`, the same bundled drafter | ~13 GB resident, ~56k usable on 24 GB |
206
213
 
207
214
  Qwen3.8-27B is **dense** (64 layers: 48 GatedDeltaNet + 16 full attention), so every
208
- parameter is on the critical path for every token and the quant is where decode speed comes
209
- from. The bits go where held-out perplexity says they pay: `lm_head` is a second full
210
- 1.27B-param tensor and is held at 5-bit, while `embed_tokens` is a lookup table whose error
211
- never compounds through a matmul, so it is cheapest. The name follows
212
- [Unsloth's convention](https://docs.unsloth.ai/) (`UD-…`), though the quant is MLX group-64
213
- affine, not a llama.cpp k-quant. The drafter ships in the same repo, pre-quantized.
215
+ parameter is on the critical path for every token and the quant is where both decode speed
216
+ and context come from: the governor prices a gigabyte of weights at about 29k tokens of
217
+ window. The ternary build is Prism ML's [Bonsai](https://huggingface.co/prism-ml/Ternary-Bonsai-2-27B-mlx-2bit)
218
+ pack of this checkpoint, repacked text-only with the base tokenizer and the drafter
219
+ bundled (Apache-2.0; created using Bonsai by Prism ML). Its projections are stored in a
220
+ rotated basis that an ordinary MLX loader would silently get wrong, so chad carries its own
221
+ loader for it and fits the decode fast-path, the verify kernel and the drafter to it. What it
222
+ costs: on code, teacher-forced perplexity is 4.49 against the 3-bit's 3.99 (+12%), while
223
+ the private eval tiers tie at 56/56. The 3-bit stays one flag away.
214
224
 
215
225
  `--model <repo or local dir>` runs different weights through the same engine and stays a
216
226
  first-class escape hatch. The drafter, the fused-attention coverage, the decode fastpath and
217
- the context governor are all fitted to the shipped checkpoint, so other weights run slower;
218
- they do not break.
227
+ the context governor are all fitted to the two checkpoints above, so other weights run
228
+ slower; they do not break.
219
229
 
220
230
  ## Installing & upgrading
221
231
 
@@ -23,13 +23,13 @@ uvx chad-code # runs anywhere; the command is still `chad`
23
23
  uvx chad-code prove # offline smoke test: 4 tiny fix-it tasks, verified, timed 🗿
24
24
  ```
25
25
 
26
- The first run asks, then downloads the model once (~13 GB) into the shared Hugging
26
+ The first run asks, then downloads the model once (~8 GB) into the shared Hugging
27
27
  Face cache. While it downloads, `cd` into a project and think of a scoped first ask:
28
28
  *"fix the failing test in `tests/test_x.py`"* lands, *"improve my codebase"* flails.
29
29
 
30
30
  chad targets 24 GB and nothing smaller. It runs below that and tells you it is doing so,
31
- but 13 GB of weights sit resident before a single token of context, so a 16 GB Mac gets a
32
- window too small to work in.
31
+ but 8 GB of weights and drafter plus a ~4 GB prefill transient sit resident before a single
32
+ token of context, so a 16 GB Mac gets a window too small to work in.
33
33
 
34
34
  The PyPI package is `chad-code`. Bare `chad` is an unrelated squatted package.
35
35
 
@@ -43,9 +43,9 @@ The PyPI package is `chad-code`. Bare `chad` is an unrelated squatted package.
43
43
  ## Same model, same Mac, stock engine
44
44
 
45
45
  What do you gain over pointing a generic local-model tool at the same weights?
46
- Qwen3.8-27B at the same `UD-Q3_K_XL` recipe (Unsloth's GGUF for
47
- llama.cpp, chad's MLX conversion of the same bit map), the same M4 Pro (24 GB), one engine
48
- resident at a time, each measured with its own benchmark on a 512-token prompt and a
46
+ Qwen3.8-27B at the `UD-Q3_K_XL` recipe (Unsloth's GGUF for llama.cpp, chad's MLX
47
+ conversion of the same bit map; chad runs it with `--model`), the same M4 Pro (24 GB), one
48
+ engine resident at a time, each measured with its own benchmark on a 512-token prompt and a
49
49
  128-token generation.
50
50
 
51
51
  | Engine | Prefill (512-tok prompt) | Decode (128 tok) | Speculative decoding |
@@ -54,11 +54,17 @@ resident at a time, each measured with its own benchmark on a 512-token prompt a
54
54
  | llama.cpp `llama-server` (build 10917), serial | 97 tok/s | 11.3 tok/s | off |
55
55
  | llama.cpp `llama-server` (build 10917) | 95 tok/s | 11.1 tok/s² | DFlash2 drafter (Q4_K_M GGUF) |
56
56
  | **chad**, serial (`CHAD_NO_DFLASH=1`) | 100 tok/s | 17.9 tok/s | off |
57
- | **chad**, default | 101 tok/s | **62.9 tok/s**¹ | DFlash2 block drafter |
57
+ | **chad** | 101 tok/s | **62.9 tok/s**¹ | DFlash2 block drafter |
58
58
 
59
59
  A 200-token function body takes roughly 18 seconds at 10.9 tok/s and 3 at 63. You wait for
60
60
  the first one and you talk to the second.
61
61
 
62
+ The weights chad ships by default are the same model in Prism ML's **ternary** build
63
+ (every projection Hadamard-rotated and stored at 2 bits): 7.2 GB resident instead of 12.3,
64
+ the same 64 tok/s drafted and 21 serial on this Mac, and the 5 GB it gives back is
65
+ context, roughly a **150k-token window** where the 3-bit landed near 56k. The like-for-like
66
+ table above stays on the 3-bit recipe because that is the quant llama.cpp has a GGUF of.
67
+
62
68
  Ollama does not get its own row: it is llama.cpp underneath, measured without speculative
63
69
  decoding, and on the same GGUF (0.32.15, Modelfile `FROM` only) it measures 96 tok/s
64
70
  prefill and the same **10.9** decode.
@@ -166,20 +172,24 @@ chad ships exactly one, downloaded once into the shared Hugging Face cache
166
172
 
167
173
  | Model | Quant | Footprint |
168
174
  |---|---|---|
169
- | [Qwen3.8-27B `UD-Q3_K_XL-DFlash2`](https://huggingface.co/nathansutton/Qwen3.8-27B-UD-Q3_K_XL-DFlash2-MLX) | 3-bit group-64 body, 5-bit `lm_head`, bundled 4-bit DFlash2 drafter | ~13 GB resident, 262k native context |
175
+ | [Qwen3.8-27B `Ternary-Bonsai-2-DFlash2`](https://huggingface.co/nathansutton/Qwen3.8-27B-Ternary-Bonsai-2-DFlash2-MLX) (default) | Prism ML's ternary build: Hadamard-rotated, 2-bit group-128, levels {−s, 0, +s}; bundled 4-bit DFlash2 drafter | ~8 GB resident, 262k native context, ~150k usable on 24 GB |
176
+ | [Qwen3.8-27B `UD-Q3_K_XL-DFlash2`](https://huggingface.co/nathansutton/Qwen3.8-27B-UD-Q3_K_XL-DFlash2-MLX) via `--model` | 3-bit group-64 body, 5-bit `lm_head`, the same bundled drafter | ~13 GB resident, ~56k usable on 24 GB |
170
177
 
171
178
  Qwen3.8-27B is **dense** (64 layers: 48 GatedDeltaNet + 16 full attention), so every
172
- parameter is on the critical path for every token and the quant is where decode speed comes
173
- from. The bits go where held-out perplexity says they pay: `lm_head` is a second full
174
- 1.27B-param tensor and is held at 5-bit, while `embed_tokens` is a lookup table whose error
175
- never compounds through a matmul, so it is cheapest. The name follows
176
- [Unsloth's convention](https://docs.unsloth.ai/) (`UD-…`), though the quant is MLX group-64
177
- affine, not a llama.cpp k-quant. The drafter ships in the same repo, pre-quantized.
179
+ parameter is on the critical path for every token and the quant is where both decode speed
180
+ and context come from: the governor prices a gigabyte of weights at about 29k tokens of
181
+ window. The ternary build is Prism ML's [Bonsai](https://huggingface.co/prism-ml/Ternary-Bonsai-2-27B-mlx-2bit)
182
+ pack of this checkpoint, repacked text-only with the base tokenizer and the drafter
183
+ bundled (Apache-2.0; created using Bonsai by Prism ML). Its projections are stored in a
184
+ rotated basis that an ordinary MLX loader would silently get wrong, so chad carries its own
185
+ loader for it and fits the decode fast-path, the verify kernel and the drafter to it. What it
186
+ costs: on code, teacher-forced perplexity is 4.49 against the 3-bit's 3.99 (+12%), while
187
+ the private eval tiers tie at 56/56. The 3-bit stays one flag away.
178
188
 
179
189
  `--model <repo or local dir>` runs different weights through the same engine and stays a
180
190
  first-class escape hatch. The drafter, the fused-attention coverage, the decode fastpath and
181
- the context governor are all fitted to the shipped checkpoint, so other weights run slower;
182
- they do not break.
191
+ the context governor are all fitted to the two checkpoints above, so other weights run
192
+ slower; they do not break.
183
193
 
184
194
  ## Installing & upgrading
185
195
 
@@ -4,7 +4,7 @@
4
4
  # import name, and command name are independent. `uvx chad-code` runs the alias
5
5
  # script added under [project.scripts].
6
6
  name = "chad-code"
7
- version = "2.1.0"
7
+ version = "2.2.0"
8
8
  description = "Claude-Code-style coding agent that runs entirely on a 24 GB Apple Silicon Mac (MLX, Qwen3.8-27B)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -4,7 +4,7 @@ A flat collection of cooperating modules behind one console script (``chad``):
4
4
  the inference engine, the tool layer, the agent loop, and the terminal UI.
5
5
  """
6
6
 
7
- __version__ = "2.1.0"
7
+ __version__ = "2.2.0"
8
8
 
9
9
  # chad sets no MLX_* runtime vars. MLX_METAL_FAST_SYNCH, MLX_MAX_OPS_PER_BUFFER
10
10
  # and MLX_MAX_MB_PER_BUFFER were each measured end-to-end on the 35B and every
@@ -753,6 +753,12 @@ class Agent:
753
753
  self._split_ok = ok
754
754
  return ok
755
755
 
756
+ def _template_extra(self) -> dict:
757
+ """The template kwargs beyond messages/tools/thinking: the reasoning effort,
758
+ when one is set (the user's, else the engine's loader-carried default)."""
759
+ effort = self.reasoning_effort or self.engine.reasoning_effort_default
760
+ return {"reasoning_effort": effort} if effort else {}
761
+
756
762
  def _render(self, thinking: bool = None):
757
763
  # `thinking` overrides self.thinking for THIS render only (no-think
758
764
  # escalation renders one step with <think> off, then restores). None => self.thinking.
@@ -772,13 +778,14 @@ class Agent:
772
778
  # malformed TypeScript it cannot repair. `low` keeps the reasoning block and
773
779
  # shrinks it. Passed only when set, and via **kwargs, so templates that do not
774
780
  # accept the argument (q3_s6's, which has no reasoning_effort at all) render
775
- # byte-identically to before.
776
- extra = {}
777
- if self.reasoning_effort:
778
- extra["reasoning_effort"] = self.reasoning_effort
781
+ # byte-identically to before. A loader may carry a default of its own
782
+ # (engine.reasoning_effort_default) for a template whose default it disagrees
783
+ # with; the user's setting still wins. `_prefix_ids` renders with the same
784
+ # kwargs: the effort lands in the system block, so a prefix priced without
785
+ # it would not be a prefix of the prompt.
779
786
  ids = self._template_ids(self.engine.tok.apply_chat_template(
780
787
  messages, tools=self._active_schemas(), add_generation_prompt=True,
781
- enable_thinking=thinking, **extra,
788
+ enable_thinking=thinking, **self._template_extra(),
782
789
  ))
783
790
  # Debug hook (env-gated, off by default): dump the first decoded render so a
784
791
  # rendered-prompt difference across environments can be diffed. Best-effort.
@@ -808,7 +815,8 @@ class Agent:
808
815
  def render1(u):
809
816
  return self._template_ids(self.engine.tok.apply_chat_template(
810
817
  [sysm, {"role": "user", "content": u}], tools=schemas,
811
- add_generation_prompt=True, enable_thinking=self.thinking))
818
+ add_generation_prompt=True, enable_thinking=self.thinking,
819
+ **self._template_extra()))
812
820
  a, b = render1("a"), render1("the quick brown fox jumps")
813
821
  n = 0
814
822
  for x, y in zip(a, b):
@@ -835,7 +843,8 @@ class Agent:
835
843
  def render1(m):
836
844
  return self._template_ids(self.engine.tok.apply_chat_template(
837
845
  [m, {"role": "user", "content": "a"}], tools=schemas,
838
- add_generation_prompt=True, enable_thinking=self.thinking))
846
+ add_generation_prompt=True, enable_thinking=self.thinking,
847
+ **self._template_extra()))
839
848
  a, b = render1({"role": "system", "content": static}), render1(sysm)
840
849
  n = 0
841
850
  for x, y in zip(a, b):
@@ -168,6 +168,9 @@ class BaseEngine(Protocol):
168
168
  effective_ctx: int # usable context window (tokens)
169
169
  cache_dir: Optional[str] # on-disk KV checkpoint dir; None disables warm-start
170
170
  _cached_ids: list # tokens currently resident in the prefix cache
171
+ # A chat-template `reasoning_effort` the loaded weights want passed when the user
172
+ # set none (a loader that disagrees with its template's default); None = template's.
173
+ reasoning_effort_default: Optional[str]
171
174
 
172
175
  # --- generation ------------------------------------------------------
173
176
  def generate(
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  """chad — a local, MLX-backed, Claude-Code-style coding agent.
3
3
 
4
- One model (Qwen3.8-27B, 3-bit, with its DFlash2 drafter), one entrypoint, run with uv:
4
+ One model (Qwen3.8-27B, ternary, with its DFlash2 drafter), one entrypoint, run with uv:
5
5
 
6
6
  uv run chad # interactive full-screen TUI
7
7
  uv run chad "fix the bug in greet.py" # one-shot, headless
@@ -38,31 +38,27 @@ if TYPE_CHECKING:
38
38
  _HERE = os.path.dirname(os.path.abspath(__file__))
39
39
  _PROJECT_ROOT = os.path.dirname(os.path.dirname(_HERE))
40
40
 
41
- # The shipped model, on Hugging Face. Naming follows Unsloth's dynamic-quant
42
- # convention so the quant scheme is recognizable (UD = Unsloth Dynamic; Q3_K_XL =
43
- # a 3-bit body with extra bits where they pay), plus an -MLX suffix for format
44
- # discoverability. The quant itself is MLX group-64 affine, not llama.cpp Q3_K
45
- # k-quants — the model card says so; the tag is for recognition, not bit-for-bit
46
- # equivalence. The repo also carries the DFlash2 block drafter, pre-quantized, in
47
- # `dflash/` (~1.1 GB): one download gets the model and its speculative decoder,
48
- # with nothing built on first run (mlx_dflash.py).
41
+ # The shipped model, on Hugging Face. The repo carries the weights AND the DFlash2
42
+ # block drafter, pre-quantized, in `dflash/` (~1.1 GB): one download gets the model
43
+ # and its speculative decoder, with nothing built on first run (mlx_dflash.py).
49
44
  #
50
45
  # Qwen3.8-27B is `qwen3_5` — DENSE (64 layers: 48 GatedDeltaNet + 16 full attention),
51
46
  # so every parameter is on the critical path for every token and shrinking the model
52
- # is the only decode lever there is. The recipe (`q3_e3h5`) is 3-bit group-64
53
- # throughout, except lm_head at 5-bit: with vocab_size 248,320 and tie_word_embeddings
54
- # false, the head is a second full 1.27B-param tensor, and the calibrated GGUF builds
55
- # of this same checkpoint are unanimous that it is the tier worth protecting while
56
- # embed_tokens — a lookup table, whose per-row error never compounds through a matmul
57
- # — is the cheapest. ~12.1 GB resident.
58
- _HF_MODEL = "nathansutton/Qwen3.8-27B-UD-Q3_K_XL-DFlash2-MLX"
47
+ # is the only decode lever there is. The shipped weights are Prism ML's ternary build:
48
+ # every projection Hadamard-rotated offline and stored as 2-bit affine group-128 whose
49
+ # three levels are {-s, 0, +s}, ~7.2 GB resident against the 3-bit recipe's 12.1 — and
50
+ # on a dense model those 5 GB are context, at the governor's measured 34,816 B/token.
51
+ # The rotation has to be undone on the activations at runtime (prism_pack.py); the
52
+ # repo is our repack of the pack with the base tokenizer and the drafter bundled.
53
+ _HF_MODEL = "nathansutton/Qwen3.8-27B-Ternary-Bonsai-2-DFlash2-MLX"
59
54
  # A dev clone that already built the weights locally should use them rather than
60
55
  # re-download — prefer this dir when present.
61
- _LOCAL_MODEL = os.path.join(_PROJECT_ROOT, "models", "Qwen3.8-27B-q3_e3h5")
56
+ _LOCAL_MODEL = os.path.join(_PROJECT_ROOT, "models", "Qwen3.8-27B-Ternary-Bonsai-2")
62
57
  # chad targets 24 GB Apple Silicon and nothing smaller. Below this the model still
63
- # loads, but the context governor has almost nothing left to spend after ~12.1 GB of
64
- # weights and the ~4.3 GB prefill transient, so the window collapses toward its floor.
65
- # We warn and proceed rather than refuse: the harness advises, the caller decides.
58
+ # loads, but the context governor has little left to spend after ~7.2 GB of weights,
59
+ # the ~1.1 GB drafter and the ~4.3 GB prefill transient, so the window shrinks toward
60
+ # its floor. We warn and proceed rather than refuse: the harness advises, the caller
61
+ # decides.
66
62
  _MIN_RAM_GB = 23.5
67
63
 
68
64
 
@@ -487,10 +483,10 @@ def _pick_model(spec=None, *, host: Host = HOST, local_model: str = _LOCAL_MODEL
487
483
 
488
484
  def _model_download_gb(model_id):
489
485
  """Approximate download size in GiB for the shipped model (for the disk preflight
490
- and the confirm prompt — display honesty, not accounting): ~12.1 GB of weights
486
+ and the confirm prompt — display honesty, not accounting): ~7.2 GB of weights
491
487
  plus the ~1.1 GB bundled DFlash2 drafter. An arbitrary `--model` is unknowable
492
488
  ahead of the resolve, so it gets the same figure."""
493
- return 13.2
489
+ return 8.3
494
490
 
495
491
 
496
492
  def _cached_weights_complete(
@@ -142,6 +142,7 @@ class CompletionEngine:
142
142
  self.timeout = timeout
143
143
  # 0 = auto: load() asks the server (/props n_ctx) so chad's window matches
144
144
  # the wall the server actually enforces; falls back to 32768 offline.
145
+ self.reasoning_effort_default = None
145
146
  self.effective_ctx = effective_ctx
146
147
  # tokenizer_id points at the HF repo whose tokenizer matches the served GGUF's
147
148
  # vocab (the served model_id is usually a GGUF repo with no tokenizer files).
@@ -518,6 +518,9 @@ class Engine:
518
518
  model: Any = field(init=False, default=None)
519
519
  tok: Any = field(init=False, default=None)
520
520
  effective_ctx: int = field(init=False, default=32768)
521
+ # A chat-template default the loaded weights need overridden (the Prism pack's
522
+ # template says xhigh where the shipped model's says medium); None = the template's.
523
+ reasoning_effort_default: Optional[str] = field(init=False, default=None)
521
524
  _cache: Any = field(init=False, default=None)
522
525
  _cached_ids: list = field(init=False, default_factory=list)
523
526
  # Token ids generated in the CURRENT turn, for the presence penalty. Reset at
@@ -541,6 +544,8 @@ class Engine:
541
544
  # dense — the MoE arm is here for `--model`.
542
545
  _is_moe: bool = field(init=False, default=False)
543
546
  _dflash: Any = field(init=False, default=None)
547
+ fastpath: bool = field(init=False, default=False) # mlx_fastpath installed
548
+ _dflash_ladder: Any = field(init=False, default=None) # learned round-cost seed
544
549
  _n_attn_heads: int = field(init=False, default=16)
545
550
  _n_kv_heads: int = field(init=False, default=0)
546
551
  _head_dim: int = field(init=False, default=0)
@@ -624,7 +629,11 @@ class Engine:
624
629
  # dense qwen3_5 hybrid; silent no-op on any other model or on failure.
625
630
  from . import mlx_fastpath
626
631
  _log_mlx_provenance()
627
- mlx_fastpath.install(self.model, model_path=path)
632
+ self.fastpath = mlx_fastpath.install(self.model, model_path=path)
633
+ if not self.fastpath and not config.flag("CHAD_NO_FASTPATH"):
634
+ # install() says why when it declines a Prism pack or fails outright;
635
+ # this is the line that makes a stock-graph run visible at all.
636
+ log.info("FASTPATH not installed: decoding on the stock op graph")
628
637
  # Fused quantized-KV decode attention: makes kv_bits=8 a speed win
629
638
  # instead of a loss. Patches mlx_lm's quantized SDPA branch
630
639
  # only; inert unless a QuantizedKVCache is actually in play.
@@ -662,9 +671,26 @@ class Engine:
662
671
  does, including the tokenizer's stop ids from the model config (which folds in
663
672
  generation_config.json; the override never touches them)."""
664
673
  model_path = _download(path)
665
- eos = load_config(model_path).get("eos_token_id")
666
- self.tok = load_tokenizer(model_path, eos_token_ids=eos)
674
+ cfg = load_config(model_path)
675
+ eos = cfg.get("eos_token_id")
676
+ # Prism's Hadamard-folded ternary packs carry weights in a rotated basis and
677
+ # declare their own model_type. mlx-lm's affine loader would find the right
678
+ # shapes, skip the activation transform and return garbage without erroring,
679
+ # so they route to chad's own loader on the declared type.
680
+ from . import prism_pack
681
+ prism = prism_pack.is_prism_pack(cfg)
682
+ with prism_pack.quiet_tokenizer_load() if prism else contextlib.nullcontext():
683
+ self.tok = load_tokenizer(model_path, eos_token_ids=eos)
667
684
  override, self.effective_ctx = self._ctx_override(path)
685
+ if prism:
686
+ if override:
687
+ cfg = {**cfg, "text_config": {**cfg["text_config"], **override}}
688
+ self.model, _ = prism_pack.load(str(model_path), cfg)
689
+ self.reasoning_effort_default = prism_pack.REASONING_EFFORT_DEFAULT
690
+ # The speculative schedule's round-cost seed is per weight width.
691
+ from . import mlx_dflash
692
+ self._dflash_ladder = list(mlx_dflash.round_costs(prism_pack.BITS))
693
+ return
668
694
  self.model, _ = load_model(model_path, model_config=override)
669
695
 
670
696
  def _read_model_shape(self, path: str) -> None:
@@ -2552,6 +2578,8 @@ class Engine:
2552
2578
  stats.generated_tokens = len(out_ids)
2553
2579
  stats.gen_ids = list(out_ids)
2554
2580
  self._cached_ids = fed_ids
2581
+ if policy is not None:
2582
+ self._dflash_ladder = policy.learned_seed()
2555
2583
  mx.clear_cache()
2556
2584
  return detok.text, stats
2557
2585
  except BaseException:
@@ -2597,7 +2625,9 @@ class _DFlashDrafter:
2597
2625
  cfg.block_size - 1))
2598
2626
  # Fresh per-turn schedule state: acceptance statistics are a property
2599
2627
  # of the current prompt/content, not of the session.
2600
- self.policy = (mlx_dflash.block_policy(self.cap)
2628
+ # The round-cost ladder is the exception: it belongs to the machine and the
2629
+ # checkpoint, so each turn starts from what the last one measured.
2630
+ self.policy = (mlx_dflash.block_policy(self.cap, eng._dflash_ladder)
2601
2631
  if eng.dflash_adaptive else None)
2602
2632
  self._ids = list(cfg.target_layer_ids)
2603
2633
  self._mask = int(cfg.mask_token_id)
@@ -450,6 +450,24 @@ def build(config: DFlashConfig):
450
450
  # first extra row too cheaply and collapsed the schedule on medium acceptance).
451
451
  BLOCK_ROUND_COSTS = (1.0, 1.76, 1.93, 2.30, 2.18, 2.20, 2.19, 2.20)
452
452
 
453
+ # The same ladder on the 2-bit ternary pack (benchmarks/spec_decode.py, a fixed-width
454
+ # arm per depth, round wall over the serial step of the same prompt). It is NOT the
455
+ # 3-bit ladder rescaled: the serial step is shorter (46-51 ms against 57), so the
456
+ # drafter forward, the rollback and the host sync are a larger share of every round,
457
+ # and rows 2..4 ride mlx's 2-bit matmul, which pays more per row than its 3-bit one.
458
+ # Measured twice, six prompt x preset cells each agreeing to +-0.05: on a cool machine
459
+ # (21.5 tok/s serial) 1.68 / 2.21 / 2.60 / 2.56 / 2.57 / 2.57 / 2.60, on a warm one
460
+ # (19.7) 1.58 / 2.09 / 2.44 / 2.39 / 2.44 / 2.44 / 2.46 — the ratio moves with the
461
+ # serial step, which is the dispatch-bound half. The seed is the midpoint; either way
462
+ # the 3-bit seed priced every width past 1 some 9-18% too cheap here, and
463
+ # WidthPolicy.learned_seed carries what a turn measures into the next.
464
+ BLOCK_ROUND_COSTS_2BIT = (1.0, 1.63, 2.15, 2.52, 2.48, 2.50, 2.50, 2.53)
465
+
466
+
467
+ def round_costs(weight_bits: Optional[int]) -> tuple:
468
+ """The measured seed ladder for a target quantized at `weight_bits`."""
469
+ return BLOCK_ROUND_COSTS_2BIT if weight_bits == 2 else BLOCK_ROUND_COSTS
470
+
453
471
 
454
472
  class WidthPolicy:
455
473
  """Cost-model width schedule: choose the verified width that maximizes
@@ -537,6 +555,29 @@ class WidthPolicy:
537
555
  return ob[0]
538
556
  return self._seed[depth] * self._unit()
539
557
 
558
+ LEARN_MIN = 4 # rounds at a depth before its wall may re-shape the seed
559
+
560
+ def learned_seed(self) -> list:
561
+ """The seed ladder for the NEXT turn: this one's, with every depth the turn
562
+ measured enough moved halfway to what it measured, in seed units (so the
563
+ result is scale-free like the seed itself — the next turn's context is a
564
+ different length and its walls a different scale).
565
+
566
+ The policy is rebuilt per turn because acceptance is a property of the
567
+ content, but the cost ladder is a property of the machine and the
568
+ checkpoint, and a turn that re-learns it from a wrong seed spends its first
569
+ rounds at the wrong width. Halfway, and only past LEARN_MIN rounds: a depth
570
+ priced out by one inflated sample (a kernel build, a rollback) is never
571
+ visited again to correct it."""
572
+ if not self._obs:
573
+ return list(self._seed)
574
+ unit = self._unit()
575
+ out = list(self._seed)
576
+ for d, (wall, n) in self._obs.items():
577
+ if n >= self.LEARN_MIN and unit > 0:
578
+ out[d] = 0.5 * out[d] + 0.5 * wall / unit
579
+ return out
580
+
540
581
  def _tail_p(self) -> float:
541
582
  """Acceptance estimate for positions >= TAIL_SPLIT. Real pooled
542
583
  observations once any wide round has run; before that, INFERENCE
@@ -806,6 +847,57 @@ def bundle_dir(model_dir: str) -> Optional[str]:
806
847
  return d if os.path.isfile(os.path.join(d, "config.json")) else None
807
848
 
808
849
 
850
+ # Drafters transfer across quantizations of one base model: a drafter reads the
851
+ # target's residual stream at its tapped layers, which a different weight quantization
852
+ # only perturbs (measured on the Prism ternary pack: 93% acceptance with the 3-bit
853
+ # model's sidecar). A checkpoint that bundles no drafter borrows the one a sibling
854
+ # repo bundles, keyed on the (hidden, layers, vocab) shape the tap needs anyway.
855
+ DONORS: dict = {(5120, 64, 248320): "nathansutton/Qwen3.8-27B-Ternary-Bonsai-2-DFlash2-MLX"}
856
+
857
+
858
+ def _donor_file(repo_id: str, filename: str, cached: bool = True) -> str:
859
+ """The local path of one file of a hub repo: the cache when it holds it (and
860
+ `cached`), a download — into the repo's CURRENT snapshot — otherwise."""
861
+ from huggingface_hub import hf_hub_download, try_to_load_from_cache
862
+ hit = try_to_load_from_cache(repo_id, filename) if cached else None
863
+ return hit if isinstance(hit, str) else hf_hub_download(repo_id, filename)
864
+
865
+
866
+ def donor_bundle_dir(key, repo_id: Optional[str], donors: Optional[dict] = None,
867
+ fetch: Optional[Callable[[str, str], str]] = None) -> Optional[str]:
868
+ """The sidecar dir a `donors` (default DONORS) sibling bundles for a target of
869
+ shape `key`, or None (no donor for the shape, the model IS the donor, or the
870
+ fetch failed). `fetch(repo_id, filename)` resolves one file; None = the hub."""
871
+ donor = (DONORS if donors is None else donors).get(key)
872
+ if not donor or donor == repo_id:
873
+ return None
874
+ resolve = fetch or _donor_file
875
+ try:
876
+ cfg = resolve(donor, f"{_BUNDLE}/config.json")
877
+ wts = resolve(donor, f"{_BUNDLE}/model.safetensors")
878
+ d = os.path.dirname(wts)
879
+ if os.path.dirname(cfg) != d and not _complete(d):
880
+ # The two files resolve independently, and a cache hit on the config
881
+ # from an older revision's snapshot next to weights downloaded into the
882
+ # current one is two directories, neither a bundle. The weights' snapshot
883
+ # is the live one: fetch the config into it.
884
+ if fetch is not None:
885
+ fetch(donor, f"{_BUNDLE}/config.json")
886
+ else:
887
+ _donor_file(donor, f"{_BUNDLE}/config.json", cached=False)
888
+ except Exception as e: # noqa: BLE001 — offline/gated: decode without the drafter
889
+ log.warning("DFlash drafter: donor bundle %s unavailable (%s); decoding "
890
+ "without it", donor, e)
891
+ return None
892
+ if not _complete(d):
893
+ log.warning("DFlash drafter: donor bundle %s resolved to a config and weights "
894
+ "in different snapshots (%s, %s); decoding without it", donor,
895
+ os.path.dirname(cfg), d)
896
+ return None
897
+ log.info("DFlash drafter: no bundle with these weights; borrowing %s's", donor)
898
+ return d
899
+
900
+
809
901
  def _complete(d: str) -> bool:
810
902
  return (os.path.isfile(os.path.join(d, "config.json"))
811
903
  and os.path.isfile(os.path.join(d, "model.safetensors")))
@@ -849,18 +941,22 @@ def ensure_bundle(model_dir: str, repo_id: Optional[str],
849
941
 
850
942
 
851
943
  def load_drafter(model: "nn.Module", model_dir: str, repo_id: Optional[str] = None,
852
- bits: int = 4, gs: int = 64) -> Optional["nn.Module"]:
944
+ bits: int = 4, gs: int = 64, donors: Optional[dict] = None,
945
+ fetch: Optional[Callable[[str, str], str]] = None) -> Optional["nn.Module"]:
853
946
  """Load the DFlash drafter bundled with the target's weights (or the dir
854
947
  CHAD_DFLASH_PATH names), bound to the target's embedding/lm_head, with the
855
948
  target tap installed. None when no drafter ships for this model or on any
856
949
  failure — DFlash is a pure speed feature, never load-bearing.
857
950
 
858
951
  `repo_id` is the model's HF repo when it came from the hub, so a bundle whose
859
- weights the base download filtered out can be completed (see ensure_bundle)."""
952
+ weights the base download filtered out can be completed (see ensure_bundle).
953
+ `donors`/`fetch` are donor_bundle_dir's (tests inject them)."""
860
954
  try:
861
955
  key = _target_key(model)
862
956
  ensure_bundle(model_dir, repo_id)
863
957
  sdir = bundle_dir(model_dir)
958
+ if sdir is None and key is not None:
959
+ sdir = donor_bundle_dir(key, repo_id, donors, fetch)
864
960
  if key is None or sdir is None:
865
961
  return None
866
962
  if not _is_sidecar(sdir):