chapter 13 / capstone · the course's final hands-on project · estimated build time 1-2 weekends
This chapter has no new theory—it twists the knowledge of the previous 13 chapters into a single end-to-end project you can actually run on your own Mac: use LoRA+SFT to make a 4B-9B open-source small model reliably emit structured pixel-art JSON. Every step is tagged with the chapter it draws on. Finish it and you complete the leap from "having studied" to "having done."
Drawing on the first rule of task-design from Chapter 11: narrow the task surface. Don't build "AI draws pictures"; build "generate JSON conforming to this schema":
schema · pixel_art_v1{
"size": 8 | 16 | 32, // canvas side length
"palette": ["#RRGGBB", ...], // 2-16 hexadecimal colors
"pixels": [[int, ...], ...] // size×size matrix, values are palette indices
}
This representational choice alone is half the project's success (Chapter 8 argued this vs. diffusion models): every rule is programmatically decidable—the JSON parses, fields are complete, hex is valid, the matrix is rectangular, indices stay in bounds. So one and the same validate() function handles three jobs: scoring the evaluation, filtering the training data, and (advanced) supplying the RLVR reward (the terminus of Chapter 8's evolution axis of supervision signals). Verifiability isn't luck—it's designed in.
Below is the web version of this validator. Required experiment: ① click the three error examples in turn—palette out of bounds (the model "wants to use" a color it never declared), uneven row length (miscounting a long sequence, the curse of tokenization, Chapter 7), and an illegal color value (treating a natural-language color name as hex)—these three are exactly the mistakes a small model makes most often before real training; ② edit the JSON directly in the text box and watch the checks turn red and green line by line as the canvas re-renders in real time (illegal cells flash bright red).
Edit freely on the left · instant render + line-by-line validation on the right · note that PASS/FAIL simultaneously drives evaluation/cleaning/reward, all three
The first rule of Chapter 8's methodology: have an evaluation before you train. Twelve stratified cases (4 easy: single color blocks; 4 medium: specified object + color scheme; 4 hard: style constraint + size 32), each running validate() + a task assertion:
import json, re, sys
from mlx_lm import load, generate
HEX = re.compile(r'^#[0-9a-fA-F]{6}$')
def validate(text: str) -> tuple[bool, str]:
try: d = json.loads(extract_json(text)) # the model may wrap it in markdown, so extract first
except Exception as e: return False, f'parse: {e}'
if not all(k in d for k in ('size','palette','pixels')): return False, 'missing fields'
if not (2 <= len(d['palette']) <= 16 and all(HEX.match(c) for c in d['palette'])):
return False, 'bad palette'
px, n = d['pixels'], d['size']
if len(px) != n or any(len(r) != n for r in px): return False, 'not rectangular'
if any(not (0 <= v < len(d['palette'])) for r in px for v in r): return False, 'index OOB'
return True, 'ok'
model, tok = load(sys.argv[1]) # base or adapter path
cases = [json.loads(l) for l in open('eval_cases.jsonl')]
passed = 0
for c in cases:
out = generate(model, tok, prompt=render_chat(c['instruction']), max_tokens=2048)
ok, why = validate(out)
print(('✓' if ok else '✗'), c['id'], why)
passed += ok
print(f'{passed}/{len(cases)} pass')
The expected result of running the base model (e.g., Qwen3-4B-Instruct): 0/12 to 2/12—it produces roughly plausible JSON, but with uneven row lengths and out-of-bounds indices (errors ②③ from the lab). This number is the frame of reference for all the work that follows.
Target: 3000-5000 "instruction → standard JSON" pairs (the sweet spot from MLX community experience). The pipeline (a miniature of Chapter 7's FineWeb philosophy):
The format is chat JSONL (Chapter 8's template + loss mask), on the order of 13M tokens.
Candidates: 4B dense / 8B dense / a same-tier hybrid architecture (linear-attention family) / a small MoE. Here are the real conclusions and the method straight from Chapter 7:
# —— Stage A: LoRA pipeline validation (about 20 minutes, the Chapter 8 calculator did the memory math) ——
python -m mlx_lm lora --model Qwen/Qwen3-4B-Instruct \
--train --data ./data --iters 600 --batch-size 4 \
--num-layers 16 --learning-rate 1e-5 # 600 steps ≈ 3 epochs (small subset)
python eval.py ./adapters # expect: 0/12 → 6-9/12, loss decreasing smoothly
# the question this step answers is "is the pipeline right", not "is the quality good"
# —— Stage B: full data, overnight run (about 13M tokens, 8B is 3-4 hours per epoch / 4B is half that) ——
python -m mlx_lm lora --model Qwen/Qwen3-8B-Instruct \
--train --data ./data_full --iters 2400 --batch-size 4 \
--num-layers 32 --learning-rate 1e-5 \
--steps-per-eval 200 --val-batches 25 # start before bed, check the val loss curve when you wake
The rhythm is exactly what Chapter 8 described: build data and evaluations by day, run training at night, check scores in the morning. Three signals worth watching: val loss falling together with train loss (no overfitting, Chapter 1), the row-length error rate of the generated samples (the most stubborn error type), and the situation where loss falls but the evaluation doesn't budge (which means the problem isn't fitting, it's the data—proceed to the next step).
Suppose after the overnight run you're at 9/12. The 3 you got wrong aren't noise, they're the work orders for the next round of data engineering (Chapter 8's controlled-experiment discipline):
| Failure case | Diagnosis | Countermeasure |
|---|---|---|
| Row length errors on 32×32 large images | long-sequence counting degrades (size-32 samples are only 8% of the data) | targeted top-up of 500 32×32 samples |
| "Retro palette" style drift | sparse data mapping style words → palettes | explicitly enumerate style words into the instruction template during programmatic generation |
| Occasional markdown-wrapped JSON | the SFT data is all bare JSON, but the system prompt didn't forbid it | unify on the data side + a system prompt explicitly stating "output JSON only"—a double safeguard |
After two or three rounds of "evaluate → attribute → top up data → retrain" it stabilizes at 11-12/12, and this loop itself (dataset + evaluation + pipeline) is the asset you've accumulated—the model is merely its current output (the judgment of Chapters 7/8 closes the loop here).
mlx_lm fuse to merge the adapter → 4-bit quantization (Chapter 9: 4-bit is the last stop before the cliff, nearly lossless for formatted-output tasks) → a local OpenAI-compatible service (mlx_lm server). 8B-int4 ≈ 4.5GB, serviceable from a single Mac mini.generate_pixel_art(prompt) → validate() → on failure, retry once with the error message → if it still fails, escalate to the flagship model. The validation-failure rate is your production monitoring metric, and tiered fallbacks preserve tail quality.