Skip to content

Commit b761454

Browse files
feat(mac): gemma-4 interactive CLI chat on the Kakeya-for-Mac (MLX) engine (#143)
* feat(mac): gemma-4 interactive CLI chat on the Kakeya-for-Mac (MLX) engine scripts/chat_mlx_kakeya.py — single-stream (B=1) gemma-4 chat over the Kakeya S5 bounded cache (SinkWindowKVCache): sliding layers bounded to sink+window, full- attn (exact) layers keep full context (S5 free lunch, no f_theta/proposer needed on gemma-4). B=1 sidesteps the MLX B>1,L=1 batched-decode bug. Interactive REPL + --scripted mode (for non-interactive bridge verification) + transcript JSON. Adds mac-bridge preset mlx-kakeya-chat-smoke (scripted 3-turn run) + manifest test (100% coverage, 29 presets). Operator runs the same script without --scripted for a real interactive chat on the Mac. Co-authored-by: FluffyAIcode <FluffyAIcode@users.noreply.github.com> * fix(mac chat): use template enable_thinking instead of raw channel marker Smoke run showed the injected <|channel>content marker leaked 'thought' text and caused greedy looping (turns 1/3 repeated). Replace with the chat template's enable_thinking flag (default off → clean direct answers), with a TypeError fallback for templates that don't accept the kwarg. Co-authored-by: FluffyAIcode <FluffyAIcode@users.noreply.github.com> * fix(mac chat): add repetition_penalty (default 1.3) to stop greedy JSON loops enable_thinking=False gave correct answers but greedy decoding looped on a repeated JSON wrapper. Add mlx_lm repetition penalty via logits_processors (with a TypeError fallback for older mlx_lm signatures). Co-authored-by: FluffyAIcode <FluffyAIcode@users.noreply.github.com> * fix(mac chat): generic loop guard — stop + trim when output degenerates into a repeat The 4-bit gemma-4-it checkpoint appends a repeating json answer-wrapper under greedy; repetition_penalty alone didn't stop it. Add a generic guard: break when a 24-char tail recurs 3+ times, and _trim_loop() keeps only the first occurrence for the stored/displayed text. The (correct) answer is always first. Co-authored-by: FluffyAIcode <FluffyAIcode@users.noreply.github.com> * fix(mac chat): strict consecutive-loop guard (no longer trims real answers) _trim_loop over-cut (dropped 'Paris' because the answer text recurs inside the model's json echo). Replace with _is_degenerate_loop: stop ONLY on a true back-to-back repeat (same 16-char block 3x consecutively); never trim stored text. Answer + a single echo is preserved. Co-authored-by: FluffyAIcode <FluffyAIcode@users.noreply.github.com> * fix(mac chat): raise default --max-new-tokens 256->1024 + show stop reason (eos/max/loop) User hit truncation ('断掉') on a long PoW explanation: exactly 256 tokens = the old default cap. Raise default to 1024 and surface the stop reason each turn (WARN when it's the max-new-tokens cap), so truncation is obvious and tunable. Co-authored-by: FluffyAIcode <FluffyAIcode@users.noreply.github.com> * fix(mac chat): stop at gemma's natural turn end (real EOS detection) — output length must not be a user knob User correctly noted: needing --max-new-tokens to avoid truncation = unusable. Root cause: <end_of_turn> was dropped from the stop set (old code used encode()+ single-token check on a special token), so the model ran PAST its turn (verbose JSON echoes / self-correction) until the max-tokens cap — that IS the '断掉' and the verbosity, same bug. Now resolve stops via tokenizer.eos_token_ids + convert_tokens_to_ids('<end_of_turn>'/'<eos>'); the cap is only a backstop. Report eos_token_ids + per-turn stop_reason; smoke now includes a long-answer prompt to confirm it stops on eos, not max. Co-authored-by: FluffyAIcode <FluffyAIcode@users.noreply.github.com> * fix(mac chat): default --max-new-tokens=2048 as a generous backstop (natural EOS stops first) Co-authored-by: FluffyAIcode <FluffyAIcode@users.noreply.github.com> * chore(mac-bridge): raise MAX_NEW_TOKENS bound 512->2048 (chat answers need it; natural EOS stops first) Co-authored-by: FluffyAIcode <FluffyAIcode@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: FluffyAIcode <FluffyAIcode@users.noreply.github.com>
1 parent 803d54e commit b761454

3 files changed

Lines changed: 318 additions & 1 deletion

File tree

inference_engine/bridge/manifest.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
# bridge is for evidence runs and debugging, not for monopolizing the
3030
# single Mac with open-ended workloads.
3131
MAX_N_SAMPLES = 50
32-
MAX_NEW_TOKENS = 512
32+
MAX_NEW_TOKENS = 2048 # backstop for chat; natural EOS stops well before this
3333
MAX_BLOCK_SIZE = 16
3434

3535
_ENV_PLACEHOLDER = re.compile(r"^\$\{ENV:([A-Z][A-Z0-9_]*)\}$")
@@ -650,6 +650,34 @@ def _harness_preset(
650650
},
651651
validate_reports=False,
652652
),
653+
Preset(
654+
name="mlx-kakeya-chat-smoke",
655+
description="Run gemma-4 on the Kakeya-for-Mac (MLX) engine via the "
656+
"interactive chat CLI in NON-interactive --scripted mode: "
657+
"single-stream generation over the Kakeya S5 bounded "
658+
"sink+window cache (sliding layers bounded; full-attn "
659+
"layers full). Writes a transcript JSON so we can verify "
660+
"gemma-4 responds coherently on the engine; the operator "
661+
"runs the same script without --scripted for a real "
662+
"interactive REPL on the Mac.",
663+
command_templates=(
664+
(
665+
"python3", "scripts/chat_mlx_kakeya.py",
666+
"--verifier-path", "${ENV:KAKEYA_MAC_VERIFIER_PATH}",
667+
"--sink", "4", "--window", "64",
668+
"--max-new-tokens", "{max_new_tokens}",
669+
"--scripted",
670+
"What is the capital of France? Answer in one short sentence."
671+
"||Explain how proof-of-work works, step by step."
672+
"||Name three primary colors.",
673+
"--output",
674+
"results/research/k3_mac_bridge_mlx_kakeya_chat.json",
675+
),
676+
),
677+
timeout_minutes=45,
678+
params={"max_new_tokens": ("int:max_new_tokens", "64")},
679+
validate_reports=False,
680+
),
653681
)
654682
}
655683

scripts/chat_mlx_kakeya.py

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
#!/usr/bin/env python3
2+
"""Interactive CLI chat with gemma-4 on the Kakeya-for-Mac engine (MLX).
3+
4+
Runs the gemma-4 MLX verifier with **Kakeya Attention's bounded sink+window KV
5+
cache (S5)**: the model's sliding-attention layers keep only ``sink + window``
6+
tokens resident, while gemma-4's native full-attention layers keep full context
7+
(the "S5 free lunch" — recall is carried by the full layers, so no f_θ/proposer
8+
restoration is needed on gemma-4). This is single-stream (B=1) generation, which
9+
sidesteps the MLX ``B>1, L=1`` batched-decode kernel bug entirely.
10+
11+
Usage (on the Mac, in the repo checkout):
12+
13+
# interactive REPL — type a message, get gemma-4's reply, blank line/Ctrl-D quits
14+
PYTHONPATH=. python3 scripts/chat_mlx_kakeya.py \
15+
--verifier-path /Users/fluffy314/kakeya-models/gemma-4-26B-A4B-it-mlx-4bit
16+
17+
# non-interactive smoke (used by the Mac-bridge preset): fixed turns -> JSON transcript
18+
PYTHONPATH=. python3 scripts/chat_mlx_kakeya.py --verifier-path <dir> \
19+
--scripted "What is the capital of France?||Now multiply 6 by 7." \
20+
--output results/research/mac_gemma4_kakeya_chat.json
21+
22+
mlx_lm / mlx are imported lazily inside ``main`` so ``--help`` works off-Mac.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import argparse
28+
import json
29+
import sys
30+
import time
31+
from pathlib import Path
32+
from typing import Any, Dict, List, Optional
33+
34+
35+
def _log(msg: str) -> None:
36+
print(f"[kakeya-chat] {msg}", file=sys.stderr, flush=True)
37+
38+
39+
def _resolve_eos(tok) -> set:
40+
"""All token ids that end gemma's turn — so generation stops NATURALLY (the
41+
user must never tune output length). gemma ends a turn on ``<end_of_turn>``
42+
(and ``<eos>``); the previous code used ``encode()`` + a single-token check
43+
which silently DROPPED ``<end_of_turn>`` (it's a special token), so the model
44+
ran past its turn (verbose echoes) until the max-tokens cap — the real bug."""
45+
eos: set = set()
46+
# mlx_lm's TokenizerWrapper exposes the authoritative stop set when present.
47+
ids = getattr(tok, "eos_token_ids", None)
48+
if ids:
49+
eos |= {int(i) for i in ids}
50+
if getattr(tok, "eos_token_id", None) is not None:
51+
eos.add(int(tok.eos_token_id))
52+
# convert_tokens_to_ids is the reliable lookup for a KNOWN special token
53+
# (unlike encode(), which may split it or add specials).
54+
unk = getattr(tok, "unk_token_id", None)
55+
for marker in ("<end_of_turn>", "<eos>"):
56+
try:
57+
tid = tok.convert_tokens_to_ids(marker)
58+
except Exception:
59+
tid = None
60+
if isinstance(tid, int) and tid >= 0 and tid != unk:
61+
eos.add(int(tid))
62+
return eos
63+
64+
65+
def _is_degenerate_loop(s: str, unit: int = 16) -> bool:
66+
"""True only on a TRUE consecutive loop: the same ``unit``-char block
67+
repeated 3x back-to-back at the tail. (Deliberately strict so an answer that
68+
merely echoes itself once — e.g. text + a json wrapper — is NOT cut.)"""
69+
if len(s) < unit * 3:
70+
return False
71+
a, b, c = s[-unit:], s[-2 * unit:-unit], s[-3 * unit:-2 * unit]
72+
return a == b == c and a.strip() != ""
73+
74+
75+
def _apply_template(tok, history, *, thinking: bool) -> List[int]:
76+
"""Encode the chat history. gemma-4 has a reasoning ("thought") channel; the
77+
clean way to get direct answers is the template's ``enable_thinking`` flag
78+
(NOT injecting a raw channel marker, which leaks 'thought' text and loops).
79+
Falls back gracefully if the template doesn't accept the kwarg."""
80+
try:
81+
ids = tok.apply_chat_template(
82+
history, add_generation_prompt=True, enable_thinking=thinking)
83+
except TypeError:
84+
ids = tok.apply_chat_template(history, add_generation_prompt=True)
85+
return ids.tolist() if hasattr(ids, "tolist") else list(ids)
86+
87+
88+
def main() -> int:
89+
ap = argparse.ArgumentParser(description="gemma-4 chat on the Kakeya-for-Mac (MLX) engine")
90+
ap.add_argument("--verifier-path", required=True,
91+
help="Local MLX gemma-4 model dir.")
92+
ap.add_argument("--sink", type=int, default=4, help="Kakeya sink tokens.")
93+
ap.add_argument("--window", type=int, default=64,
94+
help="Kakeya sliding-window tokens (S5; sliding layers).")
95+
ap.add_argument("--full-window", type=int, default=8192,
96+
help="Resident window for the full-attention (exact) layers "
97+
"— large = effectively full context (S5 recall carrier).")
98+
ap.add_argument("--max-new-tokens", type=int, default=2048,
99+
help="Backstop cap ONLY — generation stops naturally at gemma's "
100+
"<end_of_turn>, so you should not need to tune this.")
101+
ap.add_argument("--repetition-penalty", type=float, default=1.3,
102+
help="Penalize repeated tokens to stop greedy loops (1.0 = off).")
103+
ap.add_argument("--thinking", action="store_true",
104+
help="Allow gemma-4's reasoning channel (default: direct answers).")
105+
ap.add_argument("--system", default=None, help="Optional system prompt.")
106+
ap.add_argument("--scripted", default=None,
107+
help="Non-interactive: '||'-separated user turns; writes a transcript.")
108+
ap.add_argument("--output", default=None, help="Transcript JSON (scripted mode).")
109+
args = ap.parse_args()
110+
111+
import mlx.core as mx # type: ignore
112+
import mlx_lm # type: ignore
113+
from mlx_lm.generate import generate_step # type: ignore
114+
from inference_engine.backends.mlx.cache import (
115+
SinkWindowKVCache, total_kv_bytes, cache_seq_length,
116+
)
117+
from inference_engine.backends.mlx.cross_model_dlm_verifier import (
118+
resolve_mlx_text_model, mlx_full_attention_layer_indices,
119+
)
120+
121+
_log(f"loading MLX model: {args.verifier_path}")
122+
t_load = time.time()
123+
model, tok = mlx_lm.load(args.verifier_path)
124+
text_model = resolve_mlx_text_model(model)
125+
n_layers = len(text_model.layers)
126+
full_idx = set(mlx_full_attention_layer_indices(text_model))
127+
eos = _resolve_eos(tok)
128+
_log(f"loaded in {time.time()-t_load:.1f}s | layers={n_layers} "
129+
f"exact(full-attn)={sorted(full_idx)} sink={args.sink} window={args.window} "
130+
f"eos={sorted(eos)}")
131+
_log("Kakeya Attention: sliding layers bounded to sink+window; "
132+
"exact layers keep full context (S5).")
133+
134+
logits_processors = None
135+
if args.repetition_penalty and args.repetition_penalty != 1.0:
136+
try:
137+
from mlx_lm.sample_utils import make_logits_processors # type: ignore
138+
logits_processors = make_logits_processors(
139+
repetition_penalty=args.repetition_penalty)
140+
_log(f"repetition_penalty={args.repetition_penalty} enabled")
141+
except Exception as exc: # noqa: BLE001
142+
_log(f"repetition penalty unavailable ({exc}); greedy")
143+
144+
def new_cache() -> list:
145+
# S5 hybrid: exact (full-attn) layers get a large window (≈full context,
146+
# the recall carrier); sliding layers get the tight Kakeya window.
147+
return [
148+
SinkWindowKVCache(
149+
sink_size=args.sink,
150+
window_size=(args.full_window if li in full_idx else args.window),
151+
)
152+
for li in range(n_layers)
153+
]
154+
155+
def build_prompt_ids(history: List[Dict[str, str]]) -> List[int]:
156+
return _apply_template(tok, history, thinking=args.thinking)
157+
158+
def generate_turn(prompt_ids: List[int], on_delta=None) -> Dict[str, Any]:
159+
"""Single-stream greedy decode over a FRESH Kakeya bounded cache."""
160+
cache = new_cache()
161+
toks: List[int] = []
162+
shown = ""
163+
t0 = time.time()
164+
gkw: Dict[str, Any] = dict(prompt_cache=cache, max_tokens=args.max_new_tokens)
165+
if logits_processors is not None:
166+
gkw["logits_processors"] = logits_processors
167+
try:
168+
stream = generate_step(mx.array(prompt_ids), model, **gkw)
169+
first = next(stream)
170+
except TypeError: # older mlx_lm without logits_processors kwarg
171+
gkw.pop("logits_processors", None)
172+
stream = generate_step(mx.array(prompt_ids), model, **gkw)
173+
first = next(stream)
174+
175+
def _iter():
176+
yield first
177+
yield from stream
178+
179+
stop_reason = "max" # generator exhausts at max_tokens unless we break
180+
for tok_id, _ in _iter():
181+
t = int(tok_id)
182+
if t in eos:
183+
stop_reason = "eos"
184+
break
185+
toks.append(t)
186+
full = tok.decode(toks, skip_special_tokens=True)
187+
delta = full[len(shown):]
188+
if delta and on_delta is not None:
189+
on_delta(delta)
190+
shown = full
191+
if _is_degenerate_loop(full): # true back-to-back repeat → stop
192+
stop_reason = "loop"
193+
break
194+
dt = max(time.time() - t0, 1e-9)
195+
return {
196+
"text": tok.decode(toks, skip_special_tokens=True),
197+
"n_tokens": len(toks),
198+
"stop_reason": stop_reason,
199+
"decode_tps": round(len(toks) / dt, 2),
200+
"resident_kv_bytes": int(total_kv_bytes(cache)),
201+
"resident_kv_seq_len_first_layer": int(cache_seq_length(cache)),
202+
"prompt_tokens": len(prompt_ids),
203+
}
204+
205+
history: List[Dict[str, str]] = []
206+
if args.system:
207+
history.append({"role": "system", "content": args.system})
208+
209+
# ---- scripted (non-interactive) mode: for Mac-bridge verification ----
210+
if args.scripted is not None:
211+
turns = [t for t in args.scripted.split("||") if t.strip()]
212+
transcript: List[Dict[str, Any]] = []
213+
for user in turns:
214+
history.append({"role": "user", "content": user})
215+
info = generate_turn(build_prompt_ids(history))
216+
history.append({"role": "assistant", "content": info["text"]})
217+
transcript.append({"user": user, **info})
218+
_log(f"USER: {user!r}")
219+
_log(f"GEMMA-4: {info['text'][:160]!r} "
220+
f"({info['n_tokens']} tok, stop={info['stop_reason']}, "
221+
f"{info['decode_tps']} tok/s, resident_kv={info['resident_kv_bytes']/1e6:.1f}MB)")
222+
report = {
223+
"kind": "mac_gemma4_kakeya_chat", "schema_version": 1,
224+
"model_path": args.verifier_path,
225+
"engine": "Kakeya-for-Mac (MLX, S5 bounded sink+window, single-stream)",
226+
"sink": args.sink, "window": args.window, "full_window": args.full_window,
227+
"exact_layers": sorted(full_idx), "n_layers": n_layers,
228+
"eos_token_ids": sorted(eos),
229+
"max_new_tokens_cap": args.max_new_tokens,
230+
"turns": transcript,
231+
}
232+
if args.output:
233+
outp = Path(args.output)
234+
outp.parent.mkdir(parents=True, exist_ok=True)
235+
outp.write_text(json.dumps(report, indent=2), encoding="utf-8")
236+
_log(f"wrote transcript -> {outp}")
237+
else:
238+
print(json.dumps(report, indent=2))
239+
return 0
240+
241+
# ---- interactive REPL ----
242+
_log("ready. Type a message and press Enter. Blank line or Ctrl-D to quit.")
243+
while True:
244+
try:
245+
if sys.stdin.isatty():
246+
sys.stderr.write("\nyou> ")
247+
sys.stderr.flush()
248+
line = sys.stdin.readline()
249+
except KeyboardInterrupt:
250+
_log("interrupted")
251+
break
252+
if not line:
253+
break
254+
user = line.strip()
255+
if not user:
256+
break
257+
history.append({"role": "user", "content": user})
258+
sys.stderr.write("gemma-4> ")
259+
sys.stderr.flush()
260+
info = generate_turn(
261+
build_prompt_ids(history),
262+
on_delta=lambda d: (sys.stdout.write(d), sys.stdout.flush()),
263+
)
264+
sys.stdout.write("\n")
265+
sys.stdout.flush()
266+
history.append({"role": "assistant", "content": info["text"]})
267+
warn = (" [WARN: hit --max-new-tokens; raise it for longer answers]"
268+
if info["stop_reason"] == "max" else f" [stopped: {info['stop_reason']}]")
269+
_log(f"{info['n_tokens']} tok, {info['decode_tps']} tok/s, "
270+
f"resident bounded-KV {info['resident_kv_bytes']/1e6:.1f} MB "
271+
f"(sliding capped at sink+window={args.sink}+{args.window}){warn}")
272+
return 0
273+
274+
275+
if __name__ == "__main__":
276+
raise SystemExit(main())

tests/inference_engine/bridge/test_manifest.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ def test_allowlist_contains_exactly_the_documented_presets():
8080
"mlx-batched-multitenant",
8181
"mlx-batched-pad-decode",
8282
"mlx-env-probe",
83+
"mlx-kakeya-chat-smoke",
8384
"mlx-multitenant-pressure",
8485
"mlx-upgrade",
8586
"mlx-upstream-batch-probe",
@@ -135,6 +136,18 @@ def test_pad_decode_preset_carries_flag_and_forces_trimmable_cache():
135136
assert HARNESS_ENV["KAKEYA_MAC_VERIFIER_PATH"] in argv
136137

137138

139+
def test_mlx_kakeya_chat_smoke_preset_resolves():
140+
request = parse_manifest(_manifest(
141+
preset="mlx-kakeya-chat-smoke", params={"max_new_tokens": "64"}))
142+
(argv,) = build_commands(request, HARNESS_ENV)
143+
assert argv[1].endswith("chat_mlx_kakeya.py")
144+
assert HARNESS_ENV["KAKEYA_MAC_VERIFIER_PATH"] in argv
145+
assert "--scripted" in argv
146+
assert argv[argv.index("--max-new-tokens") + 1] == "64"
147+
assert not [t for t in argv if t.startswith("${ENV:")]
148+
assert not [t for t in argv if t.startswith("{") and t.endswith("}")]
149+
150+
138151
def test_drafter_parity_preset_resolves():
139152
request = parse_manifest(_manifest(
140153
preset="k3-drafter-parity", params={"block_size": "8"}))

0 commit comments

Comments
 (0)