Skip to content

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.

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.

A lumpy day: four kinds of task, and two dials on each - which model answers, and how much effort it spends thinking. Everything starts where most people leave it: pinned to the expensive corner. Re-dial each task and watch what the day costs.

  • copy tweaksmechanical · five today

    Fix the onboarding typo, reword two error strings, update the footer year - one obvious answer each.

    modeleffort
    top dollar, one answer

    The most expensive corner on the board, spent on work the cheap corner ships identically. This is where a pinned dial leaks.

  • pattern-following endpointsmechanical · three today

    Add list/create endpoints that mirror the handler in the next file over.

    modeleffort
    premium for boilerplate

    Nothing about list/create forks or surprises; the expensive reasoning has nothing to grip. Reserve this corner.

  • the intermittent failurehard · once, thankfully

    A test that fails one run in five, timing-dependent, with the cause three files from the symptom.

    modeleffort
    matched - spend it here

    A genuine reasoning problem: timing-dependent, cause far from symptom, no pattern to copy. This corner exists for exactly this task.

  • the design forkhard · once

    Choose how rule conflicts resolve in the categorization engine - several defensible designs, one gets built on.

    modeleffort
    matched - the wrong call costs a rewrite

    Several reasonable designs, and whichever wins gets built on. The delta between corners here is noise next to the cost of unpicking a bad choice later.

presets
first runs495 unitsredo tax0 unitsvs the matched day1.9×

Everything ships - no failures, no redo tax - and the day still costs 1.9× what it should. That’s the quiet leak of a pinned dial: the mechanical work bills like hard work, five and three times over. Dial it down to the cheapest corner that ships it; the hard problems keep their budget.

Numbers are illustrative - the ratios are the point (capable ≈ 5× per token, high effort ≈ 3× the tokens). The redo tax counts an underpowered task’s failed attempts plus the escalation you’d run anyway, not the hour lost to confident wrong answers.

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.

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.

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 lines
from 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 added
def 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.

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.

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.

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.