The Course

Eight lessons. Each one is: a claim → a command that tests it → the number you should see → what it means → what to do about it.

Rules:

  1. Predict before you run. Write the number down. Being wrong on record is the entire mechanism by which this becomes skill. Reading the answer teaches nothing.
  2. Change one thing. The harness is built so the context strategy is the only variable. Change two, learn zero.
  3. Lessons 1–6 cost no API calls. That's deliberate — the bottleneck is loop speed, not model access.

Time: ~4 hours to work through. Lesson 5 is the one that matters most.


Lesson 0 — Orientation (5 min)

cd lab
python3 llm.py gemini          # smoke test
python3 corpus_hard.py         # what the task is

144 docs, ~7.7k tokens if you dump all of it. 24 questions of two kinds:

  • trap — "What is the current deploy region for Project Peregrine?" A superseded

2023 doc exists that is lexically better than the current one: it repeats the project name, says "current", and has no filler diluting its term frequency.

  • multihop — "What is the budget code charged for Project Peregrine?" The answer

lives in a doc that never mentions Peregrine. You must learn the portfolio name first, then look up the portfolio.

Everything downstream is about those two failure shapes.


Lesson 1 — Accuracy is a compound number. Decompose it. (15 min)

Claim: accuracy = (fact entered the window) × (model used it). Only the first factor is yours. Measure it separately or you'll spend days tuning prompts against a ceiling you can't see.

Predict: at budget 800, what fraction of questions have the gold string present in S1_topk's assembled context?

python3 recall.py --budget 800
  strategy              recall  multihop    trap
  S0_dump_all              46%        0%     92%
  S1_topk                  71%       42%    100%
  S0b_dump_full           100%      100%    100%
predict first — click to reveal

What it means: S1's accuracy can never exceed 71%. It measured 75% end-to-end earlier — which is above its own recall, meaning the model guessed right a couple of times. That's not a win, it's noise you'd mistake for signal.

The habit: measure assembly recall before you touch a prompt. Zero API calls, one second. If recall is 40%, prompt engineering is a waste of your afternoon.


Lesson 2 — Truncation is the #1 silent killer (20 min)

Predict: S0 dumps docs in corpus order up to the budget. At 800 tokens its multihop recall is 0%. What is it at 1600?

python3 recall.py
python3 inspect_ctx.py --kind multihop --i 1 --rank 5
Q (multihop): What is the budget code charged for Project Vireo?
GOLD: BC-2781

retrieval ranking:
   2023-04-11  Vireo-region-old        <- stale doc ranks FIRST
   2026-02-19  Vireo-ownership
   2026-02-19  portfolio-Everglade-finance
   ...

strategy              gold in ctx  ctx_tok
S0_dump_all                    NO      780
S1_topk                       YES      790
predict first — click to reveal

What it means: S0 scored 8% end-to-end. Not because the model is weak — because the answer was never in the window. It correctly said UNKNOWN.

The habit: the first question on any context bug is "was the fact even there?" inspect_ctx.py answers it in one second with zero API calls. In production this is the majority of "the model is dumb" tickets.


Lesson 3 — A broken component can hide behind a good score (30 min)

Predict: on trap questions, how often does the stale doc rank above the current one in S1_topk? Trap accuracy was 100%, so presumably rarely.

python3 recall.py --rank
trap questions where BOTH docs retrieved : 12/12
  ...and the STALE one ranks HIGHER      : 12/12
predict first — click to reveal

Every single time. Trap accuracy is 100% by luck: both docs fit in an 800-token budget, and the model resolves the conflict using the dates. The ranker is completely wrong and the metric says everything is fine.

Now shrink the budget so only one doc fits:

python3 recall.py --budget 300

Multihop recall collapses to 0% for every retrieval strategy.

What it means: end-to-end accuracy is a lagging indicator. It hides broken components whenever slack elsewhere in the system compensates. Remove the slack — tighten the budget, add distractors — and the latent bug surfaces.

This is why you need component metrics, not just end-to-end scores.

Do exercise E1 in exercises.py. Target: 0/12 stale-above-current at budget 300. Verify with python3 check.py E1.


Lesson 4 — Your obvious improvements probably do nothing (20 min)

S1 → S2 (recency sort) → S3 (explicit "prefer later dates" framing). Both changes are things every RAG guide tells you to do.

Predict: how many accuracy points does S3 buy over S1?

S1_topk                75%       50%      100%
S2_topk_recency        75%       50%      100%
S3_framed              75%       50%      100%
predict first — click to reveal

Zero. All three. The framing targets stale-vs-current confusion, and traps were already at 100% — there was no headroom in the failure mode it addresses.

What it means: an intervention only helps where the corresponding failure mode is actually costing you. Without the per-kind breakdown, the aggregate would have looked plausible and you'd have shipped three no-ops and believed in them.

The habit: before building a fix, find the failure it targets in your breakdown and confirm it's non-zero. If you can't point at the number it will move, don't build it.


Lesson 5 — The wall that a bigger window cannot fix (45 min) ★

This is the most important lesson in the lab.

python3 inspect_ctx.py S1_topk --kind multihop --i 1 --show

The context contains: "Project Vireo is owned by portfolio Pinecrest." It does not contain the Pinecrest finance doc — because the word "Pinecrest" does not appear in the question. BM25 cannot retrieve on a term that doesn't exist yet.

Nor can embeddings. Nor can a 10M-token window at real corpus scale. The missing information is not relevance, it's ordering: hop B's query is a function of hop A's result.

The only fix is another turn:

S1_topk (1 call,  785 ctx tok)   multihop 50%
S5_iterative (3 calls, 345 tok)  multihop 67%

What it means: this is the actual boundary between "RAG" and "agent". Not a buzzword — a structural property of the task. If the next query term is unknowable until you read the current result, you need a loop. If it isn't, a loop is wasted money.

Diagnostic you can apply at work: can I write down every search I'll need before seeing any results? Yes → one-shot retrieval. No → you need turns.

Do exercise E2. Beat S1's 42% multihop recall with zero LLM calls — extract the entity with a regex and issue a second retrieval. Target ≥90% at ≤800 tokens. python3 check.py E2

The lesson inside the lesson: S5 spends 2 extra LLM calls on a planner. E2 gets the same structural win for free. Reach for a loop when you need one; don't reach for a model when a regex does it.


Lesson 6 — Compression is the highest-leverage move, and the easiest to botch (30 min)

S4_compressed    ctx 173 tok    multihop 33%    (S1: 785 tok, 42%)

S4 is 4.5× cheaper and worse — it compressed away the very sentence carrying the second hop.

What it means: compression that drops recall isn't compression, it's deletion. The correct order is recall first, then squeeze. Lossy compression is where agents die silently, because the loss shows up three steps later as an inexplicable wrong answer.

Do exercise E3. Take your E2 solution to ≤200 context tokens while keeping ≥90% multihop recall. Reference solution: 100% at 109 tokens — 7× smaller than S1 and 2.4× more accurate. python3 check.py E3


Lesson 7 — Your grader is a measuring instrument, and it's wrong (30 min)

The original grader used gold in pred. It scored this correct:

gold: 'Luca Moretti'
pred: 'Yuki Tanaka, Sana Iqbal, Nils Eriksen, Mira Castellanos, Luca Moretti, Lene Brandt, Emre Demir'

A grader that rewards listing every candidate rewards exactly the behaviour context engineering exists to prevent. Fixed grader, replayed from disk:

python3 regrade.py --diff
S4_compressed   67% → 58%      multihop 33% → 17%

No re-run, no new API calls. That's what JSONL-logging every trial buys you: when you discover the instrument was miscalibrated — and you will — you replay history instead of re-arguing it from memory.

What it means: every metric you write will be gameable, usually by your own optimization, silently. Attacking your own metric is not paranoia; it is eval design.

Do exercise E4. Write a strategy that scores well while doing something you'd never ship. Then fix evaluate_hard.grade() so it fails. Repeat until you can't cheat it.


Lesson 8 — Which of your conclusions are real? (30 min, uses API)

python3 evaluate_hard.py --provider groq --model openai/gpt-oss-120b --n 12

Compare per-kind numbers against the Gemini run in README.md.

Conclusions that survive a model swap were about context engineering. Ones that don't were about one model's quirks, and you should stop believing them. Structural findings (Lessons 2, 5, 6) will transfer. Margin-level findings (a 3-point difference between S2 and S3) will not.

The habit: before you generalize a result, change something you believe is irrelevant and check the result survives.


The eight rules, compressed

Context engineering

  1. Assembly recall is your accuracy ceiling. Measure it first, separately, for free.
  2. When something looks broken, ask "was the fact even in the window?" before anything else.
  3. Compress only after recall is high. Deletion masquerading as compression is the classic silent failure.
  4. An intervention only pays where its failure mode is currently costing you.
  5. If the next query term depends on the current result, you need a turn, not a bigger window.
  6. Budget is a policy about what deserves the window, not a limit on what fits.

Evals

  1. Never report one aggregate. Break down by failure kind; grade into mechanisms (correct/stale/hedged/unknown/wrong/error), never binary.
  2. Log every trial to disk. Verify the eval can fail before trusting that it passed. A saturated benchmark measures nothing.

Doing this on your own system

The transferable artifact is the shape, not the corpus:

Lab fileYour equivalent
corpus_hard.py30–50 real queries, hand-labelled, chosen to include your known failure modes
recall.pydoes the gold doc/fact make it into the assembled prompt?
inspect_ctx.pydump the exact final prompt for one input
evaluate_hard.pyrun N strategies × M queries, break down by kind, log JSONL
regrade.pyreplay logs under a new grader
check.pytargets, so "better" is defined before you start

Start with 30 labelled examples. That's enough to catch the failures in Lessons 2, 3, and 5 — which is most of what actually breaks in production.

Context Engineering + Evals — a working lab

▶ START HERE: LESSONS.md — 8 lessons, ~4 hours, mostly zero API calls.

Prefer a browser? python3 build_site.py && xdg-open course.html — one self-contained HTML file with prediction boxes, spoiler-blurred results, and saved progress.

The course is the learning material. This README is the reference / results appendix.

Workflow:
  read LESSONS.md lesson N
     -> predict the number
     -> run the command
     -> be wrong, understand why
     -> do the exercise in exercises.py
     -> python3 check.py     (pass/fail against verified targets)

Everything runs against free API keys already on this box (GEMINI_API_KEY, GROQ_API_KEY).

lab/
  LESSONS.md        THE COURSE. 8 lessons, each: claim -> command -> number -> meaning
  exercises.py      YOUR WORKBENCH. 4 stubs you fill in. This is the file you edit.
  check.py          auto-grader for the exercises. 0 API calls. Targets pre-verified.
  recall.py         assembly recall = your accuracy ceiling. 0 API calls. Run this first.
  build_site.py     renders LESSONS.md + README.md -> course.html (no deps, no server)
  llm.py            minimal OpenAI-compatible client (no deps), retries, token accounting
  corpus.py         easy synthetic corpus  (v1 — saturated, kept as a lesson)
  corpus_hard.py    hard corpus: multi-hop + lexical traps + distractor twins
  strategies.py     one-shot context assembly strategies  (S0–S4)
  agentic.py        multi-turn solvers that manage their own context  (S5–S6)
  evaluate.py       harness v1 (easy corpus)
  evaluate_hard.py  harness v2 — per-kind breakdown + cost accounting
  inspect_ctx.py    see what entered the window. 0 API calls. Your main debugger.
  regrade.py        replay past runs under a new grader. 0 API calls.
  runs/ runs_hard/  every trial, one JSON per line, diffable

Run it:

cd lab
python3 llm.py gemini                                   # smoke test
python3 corpus_hard.py                                  # inspect the task
python3 evaluate_hard.py --provider gemini --n 12 --workers 3

The measured result

gemini-3.1-flash-lite, 144 docs (~7.7k tokens whole), 12 questions × 8 strategies, budget 800 tokens, temperature 0.

strategy               acc  multihop      trap  ctx_tok  in_tok  calls
S0_dump_all             8%       0%       17%       780     918    1.0
S0b_dump_full          92%      83%      100%      8912    9301    1.0
S1_topk                75%      50%      100%       785    1011    1.0
S2_topk_recency        75%      50%      100%       785    1011    1.0
S3_framed              75%      50%      100%       785     989    1.0
S4_compressed          67%      33%      100%       173     336    1.0
S5_iterative           83%      67%      100%       345    1379    3.0
S6_iter_compressed     75%      50%      100%        65     382    2.4

Efficiency — what you actually optimize in production:

strategy               acc  in_tok/q  correct per 1k tokens
S0b_dump_full          92%      9301        0.10
S1_topk                75%      1011        0.74
S5_iterative           83%      1379        0.60
S4_compressed          67%       336        1.99
S6_iter_compressed     75%       382        1.96

What each row teaches

S0 vs S0b — truncation is the silent killer. Same strategy, same order, one capped at 800 tokens. S0 scores 8%: the relevant doc simply fell off the end and the model answered UNKNOWN honestly. Most "the model is dumb" bugs are this. Always verify the gold fact survived assembly before blaming the model.

S0b — "just use a big window" works, and costs 9x. 92% for 9.3k tokens/question. It is the accuracy ceiling and the efficiency floor (0.10 correct/1k tok). At 144 docs it fits. At 144k it does not, and you are back to engineering.

S1 → S2 → S3 — three "obvious" improvements that did nothing. Recency sorting and explicit conflict framing changed accuracy by exactly 0 points, because the trap questions were already at 100% — there was no headroom in the failure mode they target. This is the most valuable negative result in the table. Without the per-kind breakdown you would have shipped all three and called it progress.

S4 — 20x compression costs 8 points. 173 vs 785 context tokens, 67% vs 75%. It is the best strategy per token (1.99 correct/1k) and the wrong one if accuracy is the constraint. There is no "better" without a cost axis.

S5 — the loop beats the window. 83% at 345 context tokens vs S1's 75% at 785. Multi-hop went 50% → 67%. It buys this with 3 LLM calls instead of 1. A one-shot retriever structurally cannot solve hop B: the search term (Ironwood) does not exist until hop A is read. No embedding model and no larger window fixes that — only another turn does.

S6 — compression inside the loop. 65 context tokens (120x less than S0b) for the same 75% as full-context top-k. Near-tied with S4 on efficiency, better on accuracy.


The two failures that were the real lesson

1. The first benchmark saturated. v1 (corpus.py) scored every strategy at 100%. A benchmark where everything ties measures nothing. The task was one-hop, single-doc, exact-string — too easy to separate a good strategy from a bad one. Fix: build the task around the failure mode you want to detect. That is corpus_hard.py.

2. The first "hard" corpus wasn't. With 5 portfolios, top-k accidentally retrieved all of them, so hop B came free and multi-hop quietly degraded back into one-hop. Scaling to 30 portfolios made retrieval selective and the failure appear. Corollary: verify your eval can fail before trusting that it passed.

Both failures cost ~20 minutes and are worth more than the passing runs. Rate limits also silently produced 28 error grades in an early run — errors must be a distinct grade, never folded into "wrong", or infra flakiness reads as a quality regression.


The transferable rules

Context engineering

  1. Budget is a policy, not a limit. Decide what deserves the window, not what fits.
  2. Verify assembly independently of generation — gold in ctx is a one-line test that explains most failures.
  3. Compression is the highest-leverage move (20x tokens for 8 points here). Measure the trade; never assume it's free.
  4. Ordering and framing only help where there is headroom in that specific failure mode.
  5. When the next query term is unknown until you read the current result, you need a turn, not a bigger window. That is the boundary between RAG and an agent.

Evals

  1. Deterministic where possible: fixed seed, temperature 0, fixed question set.
  2. Never report a single aggregate. Break down by failure kind — the aggregate hid a 0% multi-hop score behind a respectable 75%.
  3. Grade into mechanisms (correct / stale / unknown / wrong / error), not binary. Each name points at a different fix.
  4. Score cost alongside quality. correct per 1k tokens reversed the ranking here.
  5. Log every trial to disk. Regressions get diffed, not re-argued.
  6. A saturated or unfailable eval is a broken instrument. Fix the eval first.

How to actually use this to get good

Work through LESSONS.md. Eight lessons, each structured as: a claim → the command that tests it → the number you should see → what it means → the exercise that makes you do it yourself.

The single rule that makes it work: predict the number before you run the command. Being wrong on record is the mechanism. Reading results teaches nothing.

The loop

python3 recall.py --budget 800                          # 0 calls: what's my ceiling?
python3 inspect_ctx.py --kind multihop --i 1 --rank 5    # 0 calls: did gold survive?
#   edit exercises.py
python3 check.py E2                                     # 0 calls: hit the target?
python3 evaluate_hard.py --only S1_topk,E2_two_stage     # end-to-end, costs calls
python3 regrade.py --diff                               # 0 calls: replay history

Lessons 1–7 cost zero API calls. That is deliberate — the bottleneck in learning this skill is loop speed, not model access.

Why the two zero-call tools matter most

inspect_ctx.py separates assembly bugs from model bugs. Run it and you see:

strategy              gold in ctx  ctx_tok
S0_dump_all                    NO      780
S1_topk                       YES      790
S4_compressed                 YES      191

Any NO means the model never had a chance. In this lab that single check explains S0's 8% score outright. In production it explains most "the model is dumb" tickets. This is the habit worth building: verify assembly before you touch the prompt.

regrade.py exists because the grader was wrong and the data caught it. The obvious gold in pred substring check scored this as CORRECT:

gold: 'Luca Moretti'
pred: 'Yuki Tanaka, Sana Iqbal, Nils Eriksen, Mira Castellanos, Luca Moretti, ...'

A grader that rewards listing every candidate rewards exactly the behaviour context engineering exists to prevent. Under a strict grader (new hedged category), S4_compressed drops 67% → 58%, and multi-hop 33% → 17% — replayed from disk, no re-run. Your eval is a measuring instrument, and instruments need calibration.

Drills, in order

  1. Predict before you run. Write down the expected number, then run. Calibration comes from being surprised, and only from being surprised on record.
  2. Break S1 on purpose. Set --budget 300. Watch where it collapses and which kind goes first. Truncation behaviour is the most useful thing to have felt.
  3. Write S7. Retrieve the entity from hop A, then issue a templated hop-B query without an LLM planner call. Beat S5's 83% at fewer than 3 calls.
  4. Make S3 matter. Framing scored 0 here because traps were saturated. Change corpus_hard.py so superseded docs outrank current ones badly enough that traps drop below 100%, then re-run S2/S3. Learn to build headroom before claiming a win.
  5. Break your own grader. Write a strategy that games the strict grader. Then fix the grader. Repeat until you cannot cheat it. This is what eval design is.
  6. Change one thing at a time. The whole harness is built so strategy is the only variable. Every time you change two, you learn nothing.

What transfers to real work

The corpus is fake; the failure modes are not. Truncation dropping the answer, top-k retrieving stale-but-lexically-better docs, distractor twins, and the hard wall where the next query term is unknowable until you read the current result — those are the four things that break real RAG and real agents. You will have already debugged each of them here, with ground truth available, which you never get in production.


Where to take it next

  • Add an LLM-as-judge grader for open-ended answers, then measure the judge against

your own labels — an unvalidated judge is just a second unverified model.

  • Add position sensitivity: same docs, gold at the start vs. middle vs. end.
  • Add adversarial context: a doc that confidently states the wrong answer.
  • Add a subagent boundary: give a fresh window per hop, return only the fact.

Measure what crossing the boundary costs.

  • Swap --provider groq --model openai/gpt-oss-120b and see which conclusions survive

a model change. The ones that don't were never about context engineering.