Skip to content

Commit ea87e78

Browse files
K3 Step 4 phase 1: bug4 fingerprint for Gemma 4 tokenizer + patch script
User-side evidence 2026-06-09 (commit e56303d on main): results/research/k3_mlx_verifier_diag_1781021396.json PR #99's mlx_lm.load diagnostic captured the failure with: error_type: AttributeError error_message: 'list' object has no attribute 'keys' matched_fingerprint: bug1_quant_config_list_vs_dict mlx_lm_version: 0.31.3 But the traceback shows the actual failure is in transformers' tokenizer load path, NOT in mlx_lm's quantization handling: transformers/tokenization_utils_base.py:1210 self.SPECIAL_TOKENS_ATTRIBUTES = ... + list(special_tokens.keys()) ↑ list has no .keys() Where special_tokens = self.extra_special_tokens, set during GemmaTokenizerFast.__init__ from tokenizer_config.json. So bug1's fingerprint was a string-match false positive (matched the error message text but mis-attributed the root cause to quantization config). The real bug is the Gemma 4 multimodal tokenizer's extra_special_tokens being a list when transformers 5.x expects a dict. Two changes: 1. NEW scripts/research/k3_patch_gemma4_tokenizer_config.py (~200 LOC) Reads <verifier_dir>/tokenizer_config.json, inspects extra_special_tokens shape, and converts list → dict. Three list shapes handled: a. Empty list → return empty dict (no-op semantics, shape-correct for transformers) b. List of strings → positional mapping using known Gemma 4 token names (audio_token, image_token, video_token, boi_token, eoi_token, boa_token, eoa_token); surplus entries auto-named extra_token_N c. List of dicts {name, content/value} → reduce to flat dict Idempotent (already-dict → no-op exit 0). Backs up original to tokenizer_config.json.pre-k3-patch.bak (only on first patch). Has --dry-run flag. Exit codes: 0 patched (or already a dict) 1 tokenizer_config.json missing 2 not parseable JSON 3 list shape unrecognised — manual fix needed; full content printed to stderr with Gemma 4 token-name reference Unit-tested all three shape conversions plus the unrecognised- mixed-list error path. 2. UPDATED _diagnose_mlx_load_failure in k3_feasibility_smoke.py: * Now also captures tokenizer_config.json + processor_config.json content under diag.tokenizer_config_json / diag.processor_config_json (so the next iteration's diagnostic includes the file content, not just file size from the directory listing) * NEW bug4_gemma4_extra_special_tokens_list_shape fingerprint: - Matches when traceback contains '_set_model_specific_special_tokens' OR 'tokenization_utils_base' (the call site) - OR when tokenizer_config.json's extra_special_tokens is observed to be a list directly - Suggested workaround points at the patch script * REFINED bug1_quant_config_list_vs_dict: now requires NOT bug4_traceback_match. Prevents string-match false positives when the real cause is the tokenizer path. Both fingerprints can fire only if the traceback genuinely points at quantization code (which bug1's signature is meant to cover). Tests: 307/307 v04 pass (no v04 code changes; only smoke script + new patch script in scripts/research/). User's next step: cd Kakeya-LLM-Inference-engine git pull --ff-only origin main # after this PR merges # Patch the local checkpoint (one-time, idempotent): python3 scripts/research/k3_patch_gemma4_tokenizer_config.py \ models/gemma-4-26B-A4B-it-mlx-4bit # Re-run the smoke: bash scripts/research/k3_feasibility_smoke.py \ --platform mac \ --verifier-path models/gemma-4-26B-A4B-it-mlx-4bit \ --skip-drafter # If smoke succeeds → push the new evidence (proves Step 4 unblocked # the verifier load path) # If it fails again → the new diagnostic captures the next layer's # error with tokenizer_config_json content; push for analysis What this PR does NOT yet do: * Doesn't fix the verifier load itself if extra_special_tokens has an unrecognised shape — the patch script exits 3 in that case with full diagnostic. We then write phase 2 of Step 4 based on what shape it actually has. * Doesn't address other potential mlx_lm Gemma 4 MoE bugs that may surface AFTER the tokenizer load path is unblocked. The five known- bug fingerprints in the diagnostic are designed to catch those surgically too. Stack: off main, parallel to PR #100 (Step 3a). Both can land independently; both unblock the Mac MLX critical path. Co-authored-by: FluffyAIcode <FluffyAIcode@users.noreply.github.com>
1 parent e56303d commit ea87e78

2 files changed

Lines changed: 322 additions & 1 deletion

File tree

scripts/research/k3_feasibility_smoke.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,24 @@ def _diagnose_mlx_load_failure(
255255
else:
256256
diag["config_json"] = None
257257

258+
# Tokenizer + processor configs — needed for the Gemma 4 multimodal
259+
# extra_special_tokens shape diagnosis (bug4 fingerprint, added
260+
# 2026-06-09 after the bug1 fingerprint mis-classified a tokenizer-
261+
# path failure as a quantization-config-shape failure).
262+
tokenizer_config_path = p / "tokenizer_config.json"
263+
if tokenizer_config_path.exists():
264+
try:
265+
diag["tokenizer_config_json"] = json.loads(tokenizer_config_path.read_text())
266+
except Exception as tc_e:
267+
diag["tokenizer_config_parse_error"] = f"{type(tc_e).__name__}: {tc_e}"
268+
269+
processor_config_path = p / "processor_config.json"
270+
if processor_config_path.exists():
271+
try:
272+
diag["processor_config_json"] = json.loads(processor_config_path.read_text())
273+
except Exception as pc_e:
274+
diag["processor_config_parse_error"] = f"{type(pc_e).__name__}: {pc_e}"
275+
258276
manifest_path = p / "k3_setup_manifest.json"
259277
if manifest_path.exists():
260278
try:
@@ -291,8 +309,57 @@ def _diagnose_mlx_load_failure(
291309
diag["error_message"] + "\n" + "\n".join(diag.get("traceback") or [])
292310
).lower()
293311
config = diag.get("config_json") or {}
312+
tok_config = diag.get("tokenizer_config_json") or {}
313+
314+
# Bug 4 (added 2026-06-09 after bug1 mis-classified a tokenizer-path
315+
# failure): Gemma 4 multimodal tokenizer's extra_special_tokens is
316+
# passed as a list to transformers' _set_model_specific_special_tokens
317+
# which expects a dict. Specific traceback signature:
318+
# File "tokenization_utils_base.py", line ~1210
319+
# in _set_model_specific_special_tokens
320+
# self.SPECIAL_TOKENS_ATTRIBUTES = ... + list(special_tokens.keys())
321+
# AttributeError: 'list' object has no attribute 'keys'
322+
bug4_traceback_match = (
323+
"_set_model_specific_special_tokens" in error_text
324+
or "tokenization_utils_base" in error_text
325+
)
326+
bug4_config_match = isinstance(
327+
tok_config.get("extra_special_tokens"), list,
328+
)
329+
if bug4_traceback_match or bug4_config_match:
330+
evidence_parts = []
331+
if bug4_traceback_match:
332+
evidence_parts.append(
333+
"Traceback fires inside _set_model_specific_special_tokens "
334+
"in transformers/tokenization_utils_base.py; the failing "
335+
"line calls .keys() on self.extra_special_tokens which "
336+
"should be a dict but is a list."
337+
)
338+
if bug4_config_match:
339+
evidence_parts.append(
340+
f"tokenizer_config.json's 'extra_special_tokens' field is "
341+
f"a list (len={len(tok_config['extra_special_tokens'])}); "
342+
"transformers _set_model_specific_special_tokens expects "
343+
"a dict mapping token-name → token-string."
344+
)
345+
fingerprints.append({
346+
"id": "bug4_gemma4_extra_special_tokens_list_shape",
347+
"evidence": " ".join(evidence_parts),
348+
"suggested_workaround": (
349+
"Run scripts/research/k3_patch_gemma4_tokenizer_config.py "
350+
"<verifier_path> to convert tokenizer_config.json's "
351+
"extra_special_tokens from a list to a dict (using Gemma 4 "
352+
"multimodal token names: audio_token, boa_token, eoa_token, "
353+
"image_token, boi_token, eoi_token, video_token). The "
354+
"script backs up the original to .bak and writes a patched "
355+
"version. Re-run the smoke after patching."
356+
),
357+
})
294358

295-
if "'list' object has no attribute 'keys'" in error_text:
359+
# Bug 1 (kept for backward-compat, but with refined match: only fires
360+
# when the traceback DOESN'T match the bug4 tokenizer pattern, since
361+
# that pattern shares the 'list has no keys' error message).
362+
if "'list' object has no attribute 'keys'" in error_text and not bug4_traceback_match:
296363
fingerprints.append({
297364
"id": "bug1_quant_config_list_vs_dict",
298365
"evidence": "AttributeError on .keys() suggests upstream code "
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
"""Patch Gemma 4 multimodal tokenizer_config.json so transformers 5.x
2+
``_set_model_specific_special_tokens`` accepts it.
3+
4+
Background (2026-06-09)
5+
-----------------------
6+
7+
The user-side K3 Mac smoke (after PR #99 merged) loaded the
8+
``FakeRockert543/gemma-4-26b-a4b-it-MLX-4bit`` verifier via mlx_lm and
9+
hit:
10+
11+
AttributeError: 'list' object has no attribute 'keys'
12+
13+
at::
14+
15+
transformers/tokenization_utils_base.py:1210
16+
self.SPECIAL_TOKENS_ATTRIBUTES = self.SPECIAL_TOKENS_ATTRIBUTES \\
17+
+ list(special_tokens.keys())
18+
19+
The ``special_tokens`` argument is ``self.extra_special_tokens``,
20+
which transformers 5.x expects to be a ``dict`` mapping token-name →
21+
token-string for the multimodal extras (audio_token, image_token,
22+
video_token, boi_token, eoi_token, boa_token, eoa_token).
23+
24+
The MLX-quantized variant's ``tokenizer_config.json`` ships
25+
``extra_special_tokens`` as a ``list`` (or a different shape) which
26+
breaks the ``.keys()`` call. This is an upstream-checkpoint bug that
27+
this script patches locally.
28+
29+
Usage
30+
-----
31+
32+
python scripts/research/k3_patch_gemma4_tokenizer_config.py \\
33+
models/gemma-4-26B-A4B-it-mlx-4bit
34+
35+
The script:
36+
37+
1. Reads ``<dir>/tokenizer_config.json``
38+
2. Inspects ``extra_special_tokens`` shape:
39+
* already a dict → no-op, exit 0
40+
* a list of strings → converts using known Gemma 4 token-name
41+
order (audio_token, image_token, video_token, ...)
42+
* a list of dicts (each {"name": ..., "value": ...} style) →
43+
reduces to flat dict
44+
* unrecognised shape → prints diagnostic + exits non-zero
45+
3. Backs up the original to ``tokenizer_config.json.pre-k3-patch.bak``
46+
4. Writes the patched config in place
47+
5. Prints a clear diff summary
48+
49+
Idempotent: re-running on an already-patched file is a no-op. The
50+
``.bak`` file is created only on the first successful patch (subsequent
51+
runs preserve the original backup).
52+
53+
Exit codes
54+
----------
55+
56+
0 patched (or already a dict — no-op)
57+
1 tokenizer_config.json missing
58+
2 tokenizer_config.json present but unparseable JSON
59+
3 ``extra_special_tokens`` shape unrecognised — manual fix required;
60+
diagnostic printed to stderr with the file's content
61+
"""
62+
63+
from __future__ import annotations
64+
65+
import argparse
66+
import json
67+
import sys
68+
from pathlib import Path
69+
from typing import Any, Dict
70+
71+
72+
# Gemma 4 multimodal extra-special-token name → expected order.
73+
# Order matters when extra_special_tokens is a flat list of strings
74+
# without keys: we map them positionally.
75+
#
76+
# This list is derived from Gemma 4's published config.json fields
77+
# (audio_token_id, image_token_id, video_token_id, boi_token_id,
78+
# eoi_token_id, boa_token_id, eoa_token_id) — see
79+
# https://huggingface.co/google/gemma-4-26B-A4B-it/blob/main/config.json.
80+
GEMMA4_EXTRA_SPECIAL_TOKEN_NAMES = [
81+
"audio_token",
82+
"image_token",
83+
"video_token",
84+
"boi_token",
85+
"eoi_token",
86+
"boa_token",
87+
"eoa_token",
88+
]
89+
90+
91+
def _looks_like_token_dict_entry(item: Any) -> bool:
92+
"""Detect entries like ``{"name": "audio_token", "content": "<audio>"}``
93+
which some HF tokenizer configs ship as the per-entry shape."""
94+
return isinstance(item, dict) and (
95+
("name" in item and ("content" in item or "value" in item))
96+
or "token" in item
97+
)
98+
99+
100+
def _convert_list_to_dict(
101+
extra: list, expected_names: list = GEMMA4_EXTRA_SPECIAL_TOKEN_NAMES,
102+
) -> Dict[str, str]:
103+
"""Convert an ``extra_special_tokens`` list to a dict.
104+
105+
Three list shapes handled:
106+
107+
1. Empty list → return empty dict (transformers expects dict
108+
even when there are no extras; no-op semantically but
109+
shape-correct).
110+
2. List of strings → positional mapping using ``expected_names``.
111+
Length must match (≤) ``expected_names``; surplus entries
112+
get auto-named ``extra_token_N``.
113+
3. List of dicts ``[{"name": "x", "content": "y"}, ...]`` →
114+
reduce to flat dict ``{"x": "y", ...}``.
115+
"""
116+
if not extra:
117+
return {}
118+
119+
if all(isinstance(x, str) for x in extra):
120+
out: Dict[str, str] = {}
121+
for i, val in enumerate(extra):
122+
name = (
123+
expected_names[i] if i < len(expected_names)
124+
else f"extra_token_{i}"
125+
)
126+
out[name] = val
127+
return out
128+
129+
if all(_looks_like_token_dict_entry(x) for x in extra):
130+
out = {}
131+
for entry in extra:
132+
name = entry.get("name") or entry.get("token")
133+
value = entry.get("content") or entry.get("value") or entry.get("token")
134+
if name is None or value is None:
135+
raise ValueError(
136+
f"unrecognised dict entry shape in extra_special_tokens: "
137+
f"{entry!r} (need 'name' + 'content' / 'value' keys)"
138+
)
139+
out[name] = value
140+
return out
141+
142+
raise ValueError(
143+
f"extra_special_tokens list contains mixed or unrecognised entry "
144+
f"types: {[type(x).__name__ for x in extra[:5]]}"
145+
)
146+
147+
148+
def main(argv: list | None = None) -> int:
149+
ap = argparse.ArgumentParser(description=__doc__)
150+
ap.add_argument("verifier_dir", help="Local directory containing tokenizer_config.json")
151+
ap.add_argument("--dry-run", action="store_true",
152+
help="Print what would change without writing anything")
153+
args = ap.parse_args(argv)
154+
155+
d = Path(args.verifier_dir)
156+
tc_path = d / "tokenizer_config.json"
157+
if not tc_path.is_file():
158+
print(f"ERROR: {tc_path} does not exist.", file=sys.stderr)
159+
return 1
160+
161+
try:
162+
original_text = tc_path.read_text(encoding="utf-8")
163+
cfg = json.loads(original_text)
164+
except json.JSONDecodeError as e:
165+
print(f"ERROR: {tc_path} is not valid JSON: {e}", file=sys.stderr)
166+
return 2
167+
168+
extra = cfg.get("extra_special_tokens")
169+
170+
if extra is None:
171+
print(
172+
f"[k3-patch] {tc_path}: 'extra_special_tokens' field absent. "
173+
"Nothing to patch.", file=sys.stderr,
174+
)
175+
return 0
176+
177+
if isinstance(extra, dict):
178+
print(
179+
f"[k3-patch] {tc_path}: 'extra_special_tokens' is already a "
180+
f"dict ({len(extra)} entries: {list(extra.keys())}). "
181+
"Nothing to patch.", file=sys.stderr,
182+
)
183+
return 0
184+
185+
if not isinstance(extra, list):
186+
print(
187+
f"ERROR: {tc_path}'s 'extra_special_tokens' is unexpected "
188+
f"type {type(extra).__name__} (expected list or dict).",
189+
file=sys.stderr,
190+
)
191+
print(f"Content: {json.dumps(extra)[:200]}", file=sys.stderr)
192+
return 3
193+
194+
# extra is a list — try to convert.
195+
print(
196+
f"[k3-patch] {tc_path}: 'extra_special_tokens' is a list "
197+
f"(len={len(extra)}); converting to dict.",
198+
file=sys.stderr,
199+
)
200+
print(f"[k3-patch] list content: {json.dumps(extra)[:200]}",
201+
file=sys.stderr)
202+
203+
try:
204+
converted = _convert_list_to_dict(extra)
205+
except ValueError as e:
206+
print(
207+
f"ERROR: cannot convert {tc_path}'s 'extra_special_tokens' "
208+
f"list to a dict automatically: {e}",
209+
file=sys.stderr,
210+
)
211+
print(file=sys.stderr)
212+
print("Manual fix needed. The list content is:", file=sys.stderr)
213+
print(json.dumps(extra, indent=2), file=sys.stderr)
214+
print(file=sys.stderr)
215+
print(
216+
"Edit tokenizer_config.json so 'extra_special_tokens' is a dict "
217+
"mapping token-name → token-string. Common Gemma 4 names: "
218+
f"{GEMMA4_EXTRA_SPECIAL_TOKEN_NAMES}.",
219+
file=sys.stderr,
220+
)
221+
return 3
222+
223+
print(f"[k3-patch] converted dict: {converted}", file=sys.stderr)
224+
225+
if args.dry_run:
226+
print("[k3-patch] --dry-run set; not writing.", file=sys.stderr)
227+
return 0
228+
229+
cfg["extra_special_tokens"] = converted
230+
231+
bak_path = tc_path.with_suffix(".json.pre-k3-patch.bak")
232+
if not bak_path.exists():
233+
bak_path.write_text(original_text, encoding="utf-8")
234+
print(f"[k3-patch] backup written: {bak_path}", file=sys.stderr)
235+
else:
236+
print(
237+
f"[k3-patch] backup already exists at {bak_path}; not overwriting.",
238+
file=sys.stderr,
239+
)
240+
241+
tc_path.write_text(json.dumps(cfg, indent=2, ensure_ascii=False), encoding="utf-8")
242+
print(f"[k3-patch] patched: {tc_path}", file=sys.stderr)
243+
print(file=sys.stderr)
244+
print("Re-run the smoke to verify the tokenizer load succeeds:", file=sys.stderr)
245+
print(
246+
f" bash scripts/research/k3_feasibility_smoke.py "
247+
f"--platform mac --verifier-path {args.verifier_dir} --skip-drafter",
248+
file=sys.stderr,
249+
)
250+
return 0
251+
252+
253+
if __name__ == "__main__":
254+
sys.exit(main())

0 commit comments

Comments
 (0)