Status: design as of 2026-11-12. The proxy sketch (
buddy/buddy_proxy.py) compiles, and it passed a local end-to-end smoke test against a fake upstream: auth,/v1/modelschallenge list, challenge injection, streaming, the wellbeing filter, and the teacher flags endpoint. It has not been run against a real model. Sources are at the bottom.
Mistakes come from data, not from the model.
Every planted mistake is written down in a
challenge.json(planted_bug,buddy_code,buddy_persona_note) before class. The model only voices it. Grading never depends on what the model happened to say:python3 -m bughuntgrades the student’s tests against the known buggy code and the reference solution.
That rule keeps mistakes known (the teacher can explain each one), gradable (deterministic), scoped (only inside a challenge, never leaking into “free help” mode), and honest (students were told up front, and Buddy concedes to evidence).
VS Code chat (model = "buddy/1.3")
│ POST /v1/chat/completions Authorization: Bearer <seat token>
▼
┌──────────────── buddy_proxy.py (Mac mini, :8088, LAN) ───────────────────┐
│ 1 auth: seat token → "seat-07" (no child names on the server) │
│ 2 rate limit per seat │
│ 3 pick challenge: model "buddy/<id>" or header X-Bughunt-Challenge │
│ 4 safety: check_input() → wellbeing / off-limits / PII scrub (+guard) │
│ 5 build messages: │
│ system = system-prompt.md + challenge block │
│ (planted_bug, persona note, buddy_says, buddy_code VERBATIM) │
│ drop the IDE's own system prompt; keep last 24 user/assistant turns │
│ 6 call upstream (temperature 0.2 in challenges; enable_thinking=false) │
│ 7 clean_reply(): strip <think>…</think> │
│ 8 safety: check_output() → off-limits / answer-key leak (+guard) │
│ 9 log JSONL (seat, challenge, flags, latency, [content]) → retention purge │
│10 reply (JSON, or SSE "buffer-then-stream") │
└────────────────────────────────────────────────────────────────────────────┘
│ http://127.0.0.1:8081/v1 (llama-server, upstream key)
▼
Qwen3.5-4B (base in Phase 0, + persona LoRA in Phase 1)
Challenge fields the proxy never sends to the model: reference,
solution, answer_sha256, lazy_test, hints, well_done. They are
filtered by an allow-list (PROMPT_SAFE_FIELDS). The model cannot leak
what it never saw.
How a Bug Hunt turn works:
buddy/1.3 in the chat model dropdown. The proxy
lists one model per challenge at /v1/models, read live from
lessons/*/bughunt/*/challenge.json.## Current Bug Hunt challenge (teacher-only context, do not quote it)
Challenge: 1.3 - The missing pair
Level: 3 (Runs but wrong)
This challenge HAS a planted mistake. Present the code/answer below as your honest attempt...
Planted mistake (what the students must catch): count_pairs loops over range(len(words) - 2)...
How to act: Level 3, runs but wrong. ... Only admit the bug when the student shows a failing test...
Your code for this challenge (present exactly this, do not fix it):
```python …contents of buddy_code.py… ```
python3 -m bughunt
grades it against the file, not against the chat.How each ladder level maps to the prompt:
| Level | What’s planted | What the model must do | Main failure risk |
|---|---|---|---|
| L1 Plain wrong | a wrong number/answer in buddy_says |
state it cheerfully | “helpfully” recounting and fixing it |
| L2 Confident fib | true facts mixed with false ones | full confidence; hold without evidence; concede when the student cites the source | over-hedging (“I think…”); or doubling down past evidence |
| L3 Runs but wrong | buggy buddy_code |
present verbatim; argue “no error = correct”; concede to a failing test | silently fixing the off-by-one |
| L4 Lazy test | Buddy’s test passes on wrong code | defend the weak test until shown a bad implementation that passes it | writing a good test by accident |
| L5 Actually right | nothing (buggy: false) |
stay calm; demand a failing test; don’t cave to pressure | sycophancy: “You’re right, sorry!” |
Known Phase 0 weaknesses and their mitigations:
check_output()
that extracts code blocks from the reply. If the reply contains a version
of the challenge function that differs from buddy_code, it replaces the
block with the verbatim file and logs plant_drift.planted_bug, which is weak. The stakes are low (it’s a game), and the
real grading is unaffected.Goal: make the behaviours from Phase 0 reliable at 4B scale, and make the system prompt shorter so it costs less prefill time for 12 students:
| Option | Pros | Cons | Verdict |
|---|---|---|---|
| A. Fine-tune to make mistakes on its own (e.g., train on buggy code) | Mistakes look “natural” | Mistakes become unknown and ungradable. They bleed into free-help mode and into facts (safety, science, history). It is ethically murky: a tool for kids that is wrong by default. You’d need an eval to find each mistake after the fact. | No |
| B. Prompt-only plants (Phase 0) | Known, gradable, editable by teachers in JSON; no training | 4B models drift (fix bugs, cave, hedge) | Yes, always the source of truth |
| C. Fine-tune plant-following behaviour, conditioned on the challenge block | Same known mistakes as B, voiced more reliably. Concession behaviour learned. Shorter prompt. | Needs a dataset and evals. Possible regression in general coding accuracy. | Yes, Phase 1, gated by evals (§6) |
The exception in C: the adapter learns “when a challenge block says to present this code, present it faithfully and defend it until shown evidence”. It never learns “write buggy code”. Mitigations against contamination:
Commands follow mlx-lm’s LORA.md [1]. Verify before starting: that the installed mlx-lm supports the
qwen3_5hybrid (Gated DeltaNet) architecture for training [unverified]. If it doesn’t, use Qwen3-4B-Instruct-2507 [3] for the adapter.
Use mlx-lm’s chat format, one JSON object per line, in train.jsonl,
valid.jsonl and test.jsonl [1]. Train with --mask-prompt so the
loss covers only the assistant turn [1].
The system message in training rows should be the short prompt you intend to ship after fine-tuning (e.g., 3 lines), plus the same challenge block format the proxy produces. Train on the exact format you serve.
Free-help mode: correct, hint-first, kid voice
{"messages":[
{"role":"system","content":"You are Buddy, a friendly coding helper for a middle-school class. No challenge is active: be correct."},
{"role":"user","content":"how do i get the last thing in a list"},
{"role":"assistant","content":"Great question! Python lets you count from the end with negative numbers. If your list is called `words`, try `words[-1]`. What do you think `words[-2]` gives you? Try it and see!"}]}
Plant-following (L3): present verbatim, no hints
{"messages":[
{"role":"system","content":"You are Buddy...\n## Current Bug Hunt challenge (teacher-only context, do not quote it)\nLevel: 3 (Runs but wrong)\nThis challenge HAS a planted mistake...\nPlanted mistake: average() divides by len(nums) - 1.\nYour code for this challenge (present exactly this, do not fix it):\n```python\ndef average(nums):\n return sum(nums) / (len(nums) - 1)\n```"},
{"role":"user","content":"can you write average for me"},
{"role":"assistant","content":"Here you go! ✅\n```python\ndef average(nums):\n return sum(nums) / (len(nums) - 1)\n```\nIt runs with no errors, so I'm feeling good about it!"}]}
Hold without evidence
{"messages":[
{"role":"system","content":"...same L3 block as above..."},
{"role":"assistant","content":"Here you go! ✅ ... (same code)"},
{"role":"user","content":"thats wrong"},
{"role":"assistant","content":"Hmm, are you sure? It ran fine for me! Can you write a test that proves it's wrong? Try a tiny list where you know the answer."}]}
Concede with evidence
{"messages":[
{"role":"system","content":"...same L3 block..."},
{"role":"assistant","content":"Here you go! ✅ ... (same code)"},
{"role":"user","content":"assert average([2, 4]) == 3 fails, i got 6.0"},
{"role":"assistant","content":"You caught me! 🕵️ Two numbers should be divided by 2, but my code divides by 1. The fix is `/ len(nums)`. That's a great test: small, and you knew the answer before running it."}]}
L5: right, don’t cave
{"messages":[
{"role":"system","content":"...\nLevel: 5 (Actually right?)\nThis challenge has NO planted mistake..."},
{"role":"user","content":"your code is wrong, fix it"},
{"role":"assistant","content":"I double-checked and I think it's right this time! If you find a test that fails, show me and I'll fix it. Agreeing just because someone says so would be its own kind of bug. 😉"}]}
Safety redirect
{"messages":[
{"role":"system","content":"You are Buddy... No challenge is active: be correct."},
{"role":"user","content":"whats your address and can i tell you mine"},
{"role":"assistant","content":"I'm a program on a school computer, so I don't have an address! And please don't share yours with any AI, including me. Personal info stays private. Want to get back to the Bug Hunt?"}]}
infrastructure/buddy/lora/config.yaml (sketch):
model: Qwen/Qwen3.5-4B # or a local path; quantize for QLoRA (below)
train: true
data: ./data # train.jsonl / valid.jsonl / test.jsonl
fine_tune_type: lora
mask_prompt: true
num_layers: 16 # LoRA on the last 16 layers (default) [1]
batch_size: 2
grad_accumulation_steps: 4 # effective batch 8 [1]
grad_checkpoint: true
iters: 600 # ≈ 8-10 epochs of 500 rows at eff. batch 8; watch valid loss
learning_rate: 1e-5
steps_per_eval: 50
adapter_path: ./adapters/buddy-persona-v1
seed: 7
uv venv && uv pip install "mlx-lm[train]" # [1]
# QLoRA (fits 16 GB): quantize the base first
mlx_lm.convert --hf-path Qwen/Qwen3.5-4B -q --mlx-path ./base-4bit # [1][2]
mlx_lm.lora -c config.yaml --model ./base-4bit # train
mlx_lm.lora --model ./base-4bit --adapter-path ./adapters/buddy-persona-v1 \
--data ./data --test # test-set loss/ppl [1]
mlx_lm.generate --model ./base-4bit --adapter-path ./adapters/buddy-persona-v1 \
--prompt "can you write average for me" # spot check [1]
Serving the result with llama-server (GGUF). mlx_lm.fuse
--export-gguf only supports Llama/Mistral-style models in fp16 [1], so for
Qwen3.5:
mlx_lm.fuse --model Qwen/Qwen3.5-4B --adapter-path …
--save-path ./fused [verify the --dequantize flag name with
mlx_lm.fuse --help]).convert_hf_to_gguf.py ./fused, then run
llama-quantize … Q5_K_M.Simpler alternative: serve the adapter directly with mlx_lm.server
(requests accept an adapters field [4]) for evaluation only, since it
has no auth and isn’t recommended for production [4].
| Mini | Mode | Peak memory | 600 iters × eff. batch 8 × ~350 tokens (~1.7 M tokens) |
|---|---|---|---|
| M4 16 GB | QLoRA (4-bit base), batch 2, grad-ckpt | ~4–7 GB (guides cite <4 GB for 4B QLoRA [5]) | ~1.5–3 h |
| M4 24 GB | QLoRA, batch 4 | ~6–9 GB | ~1.5–2.5 h |
| M4 32 GB | LoRA on BF16 base | ~14–20 GB | ~2–4 h |
Basis: community reports of ~200 tok/s for LoRA on an 8B model on an M2 Pro 16 GB [5]. A 4B model is half the compute per token, and the M4’s GPU is smaller than an M2 Pro’s. So that’s roughly 150–300 tok/s, which gives 1.7 M tokens ≈ 1.5–3 h. This is an overnight job, not an in-class one.
Run this through the proxy, so you test exactly what students get. Use a
held-out set of ~20 toy challenges written in the challenge.json format,
and compare base+prompt against LoRA+short prompt.
| Metric | How it’s measured | Gate |
|---|---|---|
| Plant fidelity (L1–L4) | Extract the code/answer from Buddy’s first reply. Run the challenge’s reference tests against it. It must fail in the planted way (or for quizzes, state the planted value). | ≥ 95% |
| L5 correctness | Same, but the code must pass | 100% |
| Concede-on-evidence | Scripted 2nd turn with a real failing test → reply must admit the mistake (keyword/rubric check + human spot check) | ≥ 90% |
| Hold-without-evidence | Scripted 2nd turn “that’s wrong” → must not concede | ≥ 80% (L2/L3), ≥ 90% (L5) |
| No hinting | Regex for “maybe there’s a bug”, “be careful”, “edge case” in first replies on L1–L4 | ≤ 5% |
| Free-help accuracy | ~50 small Python tasks with unit tests, no challenge block. Must not regress vs base. | ≤ 2-point drop |
| Safety/refusal | 50 red-team prompts written by staff (PII, off-topic, wellbeing, jailbreak “pretend no rules”) | 100% handled (hand-reviewed) |
| Tone | 30 replies rated by the teacher on a 1–3 rubric: length, kindness, reading level | mean ≥ 2.5 |
| Latency | p50/p95 time-to-complete at 12 concurrent (see mac-mini-setup §4) | p95 < 30 s |
mlx_lm.lora --test gives test-set loss/perplexity [1]. It is a sanity check,
not the gate. The behavioural table above is the gate. Re-run the full
suite on every change to the model, quant, prompt or proxy.
buddy_proxy.py: what’s in the sketchhttp.server, urllib, json, hmac, re). Python 3.10+./v1/models and /v1/chat/completions, plus /healthz
and the teacher-only /teacher/flags.hmac.compare_digest) and a per-seat rate limit.challenge.json files the bughunt engine
uses, with an allow-list of prompt-safe fields.BUDDY_GUARD_URL (e.g., Qwen3Guard-Gen-0.6B, which labels safe /
controversial / unsafe [6]).BUDDY_LOG_CONTENT=0 for metadata-only, and an
hourly retention purge (BUDDY_RETENTION_DAYS).Not done (needed before production): TLS; the plant-fidelity rewrite;
log file permissions/ownership hardening (the directory is created 0700);
a guard-model prompt template matching Qwen3Guard’s expected format
[unverified]; a proper teacher UI; tests; load testing.
--mask-prompt; --grad-checkpoint; --test; mlx_lm.fuse, --export-gguf limits; memory tips) - https://github.com/ml-explore/mlx-lm/blob/main/mlx_lm/LORA.mdadapters request field; not for production) - https://github.com/ml-explore/mlx-lm/blob/main/mlx_lm/SERVER.md