Course · Codex · Models & effort
Match the dial to the task's value without ever reading a price
The chapter has priced two shapes of task in budgetcli (the course’s running-example repo): the category filter - adding an optional query parameter to GET /transactions, work with one right answer - and the categorizer wiring - hooking categorise() into the CSV import, a genuine design fork. Running both through the model and effort dials produced four verdict words: matched (the cheapest corner that still earns the task), overkill (paying for reasoning the pattern already decided), underpowered (a corner that never surfaces the task’s real questions), and coin flip (a confident guess that might land right). This lesson completes the set, runs the matched corner for real - the categorizer wiring actually lands, tests and all - and puts every stop on one board without a single price tag on it.
The cost rule you can apply without a price tag
Section titled “The cost rule you can apply without a price tag”You don’t need a number to make the right call, because the relationship is fixed regardless of what the number is:
More reasoning effort means more tokens generated and more latency before the answer. Always. A bigger model compounds it.
That’s true at every price point, so the discipline doesn’t depend on the price - it depends on knowing the ratio. The question is never “what does high cost?” It’s “does this task’s value justify the extra tokens and the wait that high spends?” On the wiring - hooking categorise() into the import, where a wrong seam means re-doing it and miscategorizing money - yes, easily. On the filter - the pattern-following endpoint change - no: the extra reasoning produces the same three lines, more slowly, for more tokens. Same dial, opposite verdict, no dollar figure needed.
The two corners the grid still owes you
Section titled “The two corners the grid still owes you”Every trace so far has moved one dial at a time and landed on the setting each task actually earned. Two corners are still missing, and nobody reaches them on purpose - they’re what you get from carrying yesterday’s setting into today’s task. A light model turned up to high on the filter. A capable model turned down to low on the wiring.
> add an optional category query parameter to GET /transactions in app/main.py, filtering with ledger.by_category (model: light, model_reasoning_effort = high)
Thinking… Should the parameter be validated against categorise.py's taxonomy? Unknown categories - 404, or empty list? Separate endpoint? Weighing consistency against REST-purity… • Read app/main.py • Edited app/main.py (+3) - same three lines Done.Overkill. Same three lines, same read, and a paragraph of hesitation over questions the file had already closed. A light model can narrate hesitation just as long as a capable one - hesitation isn’t insight, and the pattern needed neither.
> wire categorise() into the CSV import path so imported transactions get their keyword-table category (model: capable, model_reasoning_effort = low)
• Edited app/main.py (+2) - txn.category in import_csv's loop Done.Coin flip. No thinking block, no fork surfaced - the route hook a light model would have shipped, from a brain that had the reach to weigh the seam and was simply never asked to spend it. The hook might still be the right one. Nothing in the trace tells you that, and nothing would have told you it was wrong either - that’s what makes the cell a coin flip and not just another underpowered.
The board, replotted
Section titled “The board, replotted”This is the same grid your traces just walked by hand - “pattern-following endpoints” is the filter’s shape, “the design fork” is the wiring’s two questions - now covering every corner at once. Move the dials and watch the redo tax appear the moment a hard task lands on an underpowered corner, and the wasted spend appear the moment an easy task lands on an overpowered one. The reading beneath the grid updates with you; read it after every move, not just the first one.
The decision matrix
Section titled “The decision matrix”Strip the widget away and the whole chapter collapses into one table: task shape down the side, dial setting across, the verdict word your traces actually produced in each cell.
| Task shape | light + low | light + high | capable + low | capable + high |
|---|---|---|---|---|
| One right answer (the category filter) | matched | overkill | overkill | overkill |
| Genuine fork (the categorizer wiring) | underpowered | underpowered | coin flip | matched |
The two rows ask different questions. The top row asks “does extra spend buy anything,” and in three of four cells it doesn’t - the pattern already decided the code, so every corner past the cheapest is overhead. The bottom row asks “does the level go looking for the fork,” and only the corner with both dials up does. capable + low is the cell people reach by accident, because a capable model sounds like enough - and it’s a confident guess, nothing more.
The matched corner, run for real
Section titled “The matched corner, run for real”Enough constructed corners. The matched cell on the fork row is this chapter’s hard task, and the narrative now executes it. The prompt goes to Codex under budget-design - capable model, high effort, read-only sandbox, on-request approvals - and what comes back is the proposal the traces kept describing: hook in parse_csv, conflicts by table order, manual posts keep their sender’s category.
The seam decision, once more, because it is the fork’s whole content: parse_csv is where a description first becomes data; the import route is its only caller; the ledger stores whatever it is handed. Hooking the route instead would leave parse_csv returning half-labeled transactions and put taxonomy knowledge in the HTTP layer.
# app/importers/csv.py - the two added linesfrom app.services.categorise import categorise... transactions.append( Transaction( description=row["description"], amount_cents=amount_cents, category=categorise(row["description"]), date=date, ) )Two lines: an import, and a category= argument in the constructor call. The conflict rule is the function’s own semantics - lowercase both sides, substring match, first row of the table that hits wins. When a description contains two keywords, the earlier row wins: order is the priority - deterministic and auditable, which is exactly what a keyword table buys over a scoring approach.
# tests/test_import.py - the two tests addeddef test_parse_csv_categorises_descriptions(): transactions = parse_csv(SAMPLE_CSV) assert transactions[0].category == "dining" # Starbucks Coffee assert transactions[1].category == "groceries" # Whole Foods Market assert transactions[2].category == "transport" # Uber Ride
def test_parse_csv_leaves_unmatched_uncategorised(): transactions = parse_csv( "date,description,amount\n2024-03-18,corner bakery,4.50\n" ) assert transactions[0].category == "uncategorised"Run pytest and the suite reads 9 passed - the 7 the course’s continuation carried in, plus these two. (This continuation is the chapter’s constructed scenario, labeled like its traces; the companion repo ships the pre-wiring state, six tests.) Pinned by tests, the wiring holds: an imported Starbucks row lands dining on every future run, and a category filter no longer returns empty for everything because nothing was ever categorized.
What this doesn’t solve
Section titled “What this doesn’t solve”A matched verdict is not a correctness guarantee. It means the level spent roughly the right amount of effort finding an answer - you still read the diff and the proposal the way any reviewed change deserves (the first-change lesson covers that habit). And the dial can’t diagnose the task’s shape for you: treat the wiring as boilerplate, or the filter as a design problem, and you land in the wrong row of the table however carefully you read the output.
It doesn’t substitute for the rules file, either. The wiring edit landed inside the very block where app/importers/csv.py constructs a Transaction - and a few lines above the new argument, the float parse still turns amount strings into cents through int(float(...)). Leaving it was correct, by ticket scope: the wiring didn’t ask for it, and out-of-scope edits are how reviews lose trust. But matched effort enforced nothing. The parse-path rule is still a proposal in AGENTS.md, and the float is still live.
One more ceiling, carried over from the dial page: the level list is model-dependent - xhigh, max, and ultra in particular are not available everywhere. Whether your session offers the deepest gears depends on model and auth - worth checking before you plan an afternoon around a gear you might not have.
What recurs and what does not
Section titled “What recurs and what does not”Set this chapter’s output beside the rules chapter’s. The AGENTS.md audit bought understanding that recurs automatically, because a file Codex reads carries it into every session. The gear-matching bought code plus a judgment - and the judgment recurs only if something carries it. That is what the profiles are for: budget-build and budget-design now hold the pairings on disk, one flag away from every future session.
What landed, and what stays queued
Section titled “What landed, and what stays queued”The afternoon’s tally, stated plainly. The categorizer is wired - parse_csv is the seam, table order is the tiebreak, POST /transactions keeps its sender’s category - and the suite pins it at 9 passing. budget-design sits in ~/.codex, carrying high effort and the model selection behind on-request and a read-only fence. The reflex is installed: read the task’s shape first, then reach for the gear that shape earns.
Still queued, deliberately, is the money-parse hardening. The float in app/importers/csv.py survived its second visit - out of scope, unenforced, one proposed sentence against it. The next chapter starts exactly there, with a real audit of every path that parses, stores, sums, or formats money. And it asks this chapter’s question from a new angle: when the work is noisy and the reading is heavy, where should it think - outside the window that has to hold the answer? That is Subagents.