Use Sutton's Bitter Lesson to compare rules, learning, and search through a measured AI-incident routing lab.
You just built a conventional ML product path: time-safe features, deployed predictive models, and monitoring gates that preserve held-out evidence and an audit trail. Now comes a harder design question: when your system is wrong, should you write another rule, learn from more examples, or spend more compute searching among possible answers?
Rich Sutton's 2019 essay The Bitter Lesson gives a demanding answer. Across long stretches of AI history, he argues, methods that can use increasing computation eventually outperform systems built around expert-written knowledge. He names two broadly scalable methods: learning, which improves a model from experience, and search, which explores choices before acting.[1]
More compute doesn't automatically fix a system. You'll build a tiny AI-incident router, see when rules are useful, measure where they fail, and decide what additional budget can honestly buy.
Suppose Alex writes issue #48291: "Decode latency spiked after yesterday's reranker deploy." An AI operations system must choose a route: serving latency, answer quality, or access/auth.
A rule can say if "latency" in text: route_to("latency"). That works on wording the author anticipated. A learner can fit patterns from labeled issues such as TTFT spike, unsupported citation, and OAuth callback failed. A search step can generate several possible actions and check each one against evaluation evidence before committing.
Those are different ways to spend effort:
| Approach | What a human specifies | What can improve later | Failure to watch |
|---|---|---|---|
| Rules | Keywords and branches | More rules | New phrasing escapes the rulebook |
| Learning | Data, objective, evaluation | More clean labels and training compute | Bad labels or leakage teach the wrong behavior |
| Search | Candidates and a checker | More candidate or verification budget | A weak checker rewards the wrong answer |
Sutton's thesis concerns the long-run direction of research, not a ban on rules. A promotion gate or a required human approval can be exactly the right safety boundary. The warning is about making a growing rulebook carry the core intelligence of a messy task.
Start with a router that a developer can ship in an afternoon. Three keywords cover obvious cases.
1cases = [
2 ("decode timeout in prod", "latency"),
3 ("tokens arrive slowly", "latency"),
4 ("answer cites wrong doc", "quality"),
5 ("citation points to stale source", "quality"),
6 ("password reset please", "access"),
7 ("can't authenticate", "access"),
8]
9
10def rule_route(text: str) -> str:
11 text = text.lower()
12 rules = {"timeout": "latency", "citation": "quality", "password": "access"}
13 for keyword, route in rules.items():
14 if keyword in text:
15 return route
16 return "manual_review"
17
18predictions = [(text, expected, rule_route(text)) for text, expected in cases]
19correct = sum(expected == predicted for _, expected, predicted in predictions)
20misses = [text for text, expected, predicted in predictions if expected != predicted]
21
22print(f"correct={correct}/{len(cases)}")
23print("misses:", misses)
24assert correct == 31correct=3/6
2misses: ['tokens arrive slowly', 'answer cites wrong doc', "can't authenticate"]The router isn't foolish. It catches exact, high-signal phrases quickly. Its limit is visible: compute won't make those three conditions recognize tokens arrive slowly or can't authenticate. A human must anticipate and encode every expansion.
Now add rules that fix today's misses, then test tomorrow's wording.
1old_cases = [
2 ("decode timeout in prod", "latency"),
3 ("tokens arrive slowly", "latency"),
4 ("answer cites wrong doc", "quality"),
5 ("citation points to stale source", "quality"),
6 ("password reset please", "access"),
7 ("can't authenticate", "access"),
8]
9new_cases = [
10 ("TTFT spiked today", "latency"),
11 ("response used unsupported claim", "quality"),
12 ("OAuth callback fails again", "access"),
13]
14
15expanded_rules = {
16 "timeout": "latency",
17 "tokens": "latency",
18 "cites": "quality",
19 "citation": "quality",
20 "stale source": "quality",
21 "password": "access",
22 "authenticate": "access",
23}
24
25def route(text: str) -> str:
26 lowered = text.lower()
27 for keyword, label in expanded_rules.items():
28 if keyword in lowered:
29 return label
30 return "manual_review"
31
32old_score = sum(route(text) == label for text, label in old_cases)
33new_score = sum(route(text) == label for text, label in new_cases)
34print(f"rules={len(expanded_rules)} old_set={old_score}/6 new_set={new_score}/3")
35print("new misses:", [text for text, label in new_cases if route(text) != label])
36assert old_score == 6 and new_score == 01rules=7 old_set=6/6 new_set=0/3
2new misses: ['TTFT spiked today', 'response used unsupported claim', 'OAuth callback fails again']The second result is the treadmill Sutton warns about. More engineer-hours repair yesterday's errors but don't automatically create a method that adapts to tomorrow's distribution.
A learned system needs a representation. Before tokenization becomes a full lesson, use the smallest representation possible: lowercase words. An issue becomes a set of observed terms, and training records which route each term appeared with.
1import re
2from collections import Counter
3
4training = [
5 ("decode latency timeout", "latency"),
6 ("ttft spike tokens", "latency"),
7 ("wrong citation unsupported answer", "quality"),
8 ("stale source hallucination", "quality"),
9 ("password login access", "access"),
10 ("oauth callback auth", "access"),
11]
12
13def tokens(text: str) -> list[str]:
14 return re.findall(r"[a-z]+", text.lower())
15
16by_label = {}
17for text, label in training:
18 by_label.setdefault(label, Counter()).update(tokens(text))
19
20print("quality evidence:", sorted(by_label["quality"]))
21print("latency evidence:", sorted(by_label["latency"]))
22assert by_label["quality"]["citation"] == 11quality evidence: ['answer', 'citation', 'hallucination', 'source', 'stale', 'unsupported', 'wrong']
2latency evidence: ['decode', 'latency', 'spike', 'timeout', 'tokens', 'ttft']That representation is primitive, but its source of knowledge is different from the rulebook. No engineer chose that citation or ttft should indicate a route. Labeled examples supplied that association.
Use those counts as a tiny learned classifier: score each route by how many words it shares with a new issue.
1import re
2from collections import Counter, defaultdict
3
4training = [
5 ("decode latency timeout", "latency"),
6 ("ttft spike tokens", "latency"),
7 ("wrong citation unsupported answer", "quality"),
8 ("stale source hallucination", "quality"),
9 ("password login access", "access"),
10 ("oauth callback auth", "access"),
11]
12held_out = [
13 ("latency spike tokens", "latency"),
14 ("unsupported citation answer", "quality"),
15 ("reset password", "access"),
16 ("stale source answer", "quality"),
17 ("bad graph query", "manual_review"),
18]
19
20def tokenize(text: str) -> list[str]:
21 return re.findall(r"[a-z]+", text.lower())
22
23counts = defaultdict(Counter)
24for text, label in training:
25 counts[label].update(tokenize(text))
26
27def predict(text: str) -> str:
28 words = tokenize(text)
29 scores = {label: sum(counter[word] for word in words) for label, counter in counts.items()}
30 best_score = max(scores.values())
31 winners = [label for label, score in scores.items() if score == best_score]
32 return winners[0] if best_score > 0 and len(winners) == 1 else "manual_review"
33
34results = [(text, label, predict(text)) for text, label in held_out]
35correct = sum(expected == predicted for _, expected, predicted in results)
36print(f"held_out={correct}/{len(held_out)}")
37print("predictions:", [predicted for _, _, predicted in results])
38assert correct == 51held_out=5/5
2predictions: ['latency', 'quality', 'access', 'quality', 'manual_review']Don't confuse this tiny word-overlap model with an LLM. Its role is to make the principle observable: a general learning procedure can absorb new labeled examples without a developer adding a condition for each phrase. When no observed word supports a unique route, the router abstains with manual_review instead of turning a tie into an arbitrary production decision.
1import re
2from collections import Counter, defaultdict
3
4base = [
5 ("timeout tokens", "latency"),
6 ("wrong citation", "quality"),
7 ("password reset", "access"),
8]
9issue = "bad graph query"
10
11def train_and_predict(rows: list[tuple[str, str]], text: str) -> str:
12 counts = defaultdict(Counter)
13 for example, label in rows:
14 counts[label].update(re.findall(r"[a-z]+", example.lower()))
15 words = re.findall(r"[a-z]+", text.lower())
16 scores = {label: sum(counter[word] for word in words) for label, counter in counts.items()}
17 best_score = max(scores.values())
18 winners = [label for label, score in scores.items() if score == best_score]
19 return winners[0] if best_score > 0 and len(winners) == 1 else "manual_review"
20
21before = train_and_predict(base, issue)
22after = train_and_predict(base + [("bad graph query citation", "quality")], issue)
23print(f"before_correction={before}")
24print(f"after_correction={after}")
25assert before == "manual_review" and after == "quality"1before_correction=manual_review
2after_correction=qualityBefore correction, the safe answer is manual_review: the learner has no matching evidence. The correction alone doesn't prove generalization. You'd still evaluate on untouched issues, just as you did for datasets and training loops. It does show a useful operational property: production feedback can become evidence for the next training run instead of another permanent branch.
Learning isn't the only scalable method in Sutton's essay. Search spends computation at decision time. For an AI-operations agent, search might mean proposing multiple actions, retrieving evidence for each, and sending the best supported route forward.
4 to find the only candidate with both run and metric evidence.This next miniature isn't a language model. It's a transparent candidate-and-verifier loop that lets you see why additional inference budget helps only when better candidates and a meaningful checker exist.
1issue = "RAG answer failed citation_precision on run R42"
2candidates = [
3 ("quality", "Retry later.", []),
4 ("promote", "Promote anyway.", []),
5 ("quality", "Open eval trace.", ["run R42"]),
6 ("quality", "Open eval trace; block promotion until citation_precision recovers.", ["run R42", "citation_precision"]),
7]
8
9def verifier(candidate: tuple[str, str, list[str]]) -> int:
10 route, _, evidence = candidate
11 if route != "quality":
12 return -1
13 return len(evidence)
14
15for budget in (1, 2, 4):
16 selected = max(candidates[:budget], key=verifier)
17 print(f"budget={budget} route={selected[0]} evidence={len(selected[2])} action={selected[1]}")
18
19assert verifier(max(candidates, key=verifier)) == 21budget=1 route=quality evidence=0 action=Retry later.
2budget=2 route=quality evidence=0 action=Retry later.
3budget=4 route=quality evidence=2 action=Open eval trace; block promotion until citation_precision recovers.The first candidate is cheap and weak. With four candidates, the verifier can choose an action tied to eval-run and metric evidence. If every candidate were wrong, or if the verifier rewarded promotion without release evidence, extra search would amplify error instead.
Snell and collaborators study this question for LLM reasoning: their experiments find that test-time strategies depend on prompt difficulty, and an adaptive allocation can use inference computation more efficiently than a fixed best-of- baseline on their evaluated tasks.[2] That result supports a conditional claim, not "thinking longer always works."
Language models give learning an unusually clean objective: predict the next token. Kaplan and collaborators measured smooth empirical power-law relationships between cross-entropy loss and model size, dataset size, and training computation for the Transformer language models they studied.[3] That's evidence that a general learning objective can turn additional scale into predictable improvement within an experimental regime.
For a dense decoder-only Transformer, engineers often estimate training compute with:
Here, is training floating-point operations (FLOPs), is the number of model parameters, and is the number of training tokens. The factor 6 is a planning approximation for forward and backward passes, not a promise about hardware throughput or model quality.[4]
1def dense_training_flops(parameters: int, tokens: int) -> int:
2 return 6 * parameters * tokens
3
4plans = [
5 ("small study", 1_000_000_000, 20_000_000_000),
6 ("larger run", 7_000_000_000, 140_000_000_000),
7]
8
9for name, parameters, tokens in plans:
10 flops = dense_training_flops(parameters, tokens)
11 print(f"{name}: {flops / 1e21:.2f} zettaFLOPs")
12
13assert dense_training_flops(7_000_000_000, 140_000_000_000) == 5_880_000_000_000_000_000_0001small study: 0.12 zettaFLOPs
2larger run: 5.88 zettaFLOPsMore compute isn't the same as better allocation. Hoffmann and collaborators trained more than 400 models and reported that, under their compute-optimal fits, parameter count and training-token count should scale together: doubling one calls for roughly doubling the other.[4]
Use the FLOPs approximation to expose the tradeoff under one fixed budget. This calculation doesn't select the best model; it only tells you how many tokens each model size can afford at that budget.
1budget = 6 * 7_000_000_000 * 140_000_000_000
2model_sizes = [1_000_000_000, 7_000_000_000, 14_000_000_000]
3
4for parameters in model_sizes:
5 affordable_tokens = budget // (6 * parameters)
6 print(f"N={parameters / 1e9:.0f}B -> D={affordable_tokens / 1e9:.0f}B tokens")
7
8assert budget // (6 * 14_000_000_000) == 70_000_000_0001N=1B -> D=980B tokens
2N=7B -> D=140B tokens
3N=14B -> D=70B tokensThat calculation matters because "make the model bigger" can consume the budget needed to expose it to sufficient data. A research scientist doesn't ask only how much compute is available. They ask how to allocate it, what evaluation will reveal, and which failure mode an extra unit of compute addresses.
Sutton's essay supports a broad historical pattern. A careful reader should also notice where the evidence stops.
| Domain | What the source supports | What you shouldn't claim from it |
|---|---|---|
| Chess and Go | Sutton describes delayed success from scaled search and learning; AlphaZero learned chess, shogi, and Go from self-play with only game rules and reached superhuman play within 24 hours.[1][5] | That search eliminates every useful prior or safety rule |
| Speech and vision | Sutton points to statistical/deep-learning methods replacing increasingly elaborate human feature engineering.[1] | That modern architectures contain no inductive biases |
| Language models | Scaling studies measure improvement of Transformer language models as model size, tokens, and compute change.[3][4] | That LLM pretraining proves Sutton's full claim about agents learning from experience |
That last distinction is important. Internet text contains human-written knowledge. Training on more of it can be a strong general procedure while still relying on human-produced data. Treat the Bitter Lesson as a research heuristic: prefer methods that can absorb more evidence and compute, then demand measurement.
Rules belong at contractual boundaries, not inside a growing list of guessed user intents. For an AI release system, the model can suggest a promotion route, but policy can require review for a failed gate or missing eval evidence.
1def release_action(proposed_route: str, eval_score: float, evidence: set[str]) -> str:
2 if proposed_route == "promote" and eval_score < 0.95:
3 return "human_review_failed_gate"
4 if proposed_route == "promote" and "eval_report" not in evidence:
5 return "request_evidence"
6 return proposed_route
7
8examples = [
9 ("promote", 0.98, {"eval_report"}),
10 ("promote", 0.91, {"eval_report"}),
11 ("promote", 0.98, set()),
12]
13decisions = [release_action(*example) for example in examples]
14print("decisions:", decisions)
15assert decisions == ["promote", "human_review_failed_gate", "request_evidence"]1decisions: ['promote', 'human_review_failed_gate', 'request_evidence']The rule here isn't pretending to understand language. It protects an action with business and safety consequences. Learning handles messy phrasing; the gate controls what the learned component may execute.
Finally, leave a receipt. This one records the tiny lesson fixture, not a launch approval. A system that consumes more compute but can't report its evaluated split, training procedure, inference budget, verifier, policy gates, and escalation monitor isn't ready for a production experiment.
1from hashlib import sha256
2from json import dumps
3
4evaluation = {
5 "split": "incident-router-held-out-v1",
6 "correct": 5,
7 "total": 5,
8 "manual_review_routes": 1,
9}
10evaluation["accuracy"] = evaluation["correct"] / evaluation["total"]
11receipt = {
12 "component": "incident-router-word-overlap-v1",
13 "training": {
14 "procedure": "token-overlap-counts-v1",
15 "examples": 6,
16 },
17 "evaluation": evaluation,
18 "search": {
19 "candidate_budget": 4,
20 "verifier": "eval-evidence-v1",
21 },
22 "policy_gates": ["promotion_below_0_95_requires_review", "promotion_requires_eval_report"],
23 "monitor": "manual_escalation_rate",
24}
25payload = dumps(receipt, sort_keys=True, separators=(",", ":"))
26
27print(dumps(receipt, indent=2, sort_keys=True))
28print("receipt sha256:", sha256(payload.encode()).hexdigest()[:12])
29assert receipt["search"]["candidate_budget"] == 4
30assert receipt["evaluation"]["accuracy"] == 1.01{
2 "component": "incident-router-word-overlap-v1",
3 "evaluation": {
4 "accuracy": 1.0,
5 "correct": 5,
6 "manual_review_routes": 1,
7 "split": "incident-router-held-out-v1",
8 "total": 5
9 },
10 "monitor": "manual_escalation_rate",
11 "policy_gates": [
12 "promotion_below_0_95_requires_review",
13 "promotion_requires_eval_report"
14 ],
15 "search": {
16 "candidate_budget": 4,
17 "verifier": "eval-evidence-v1"
18 },
19 "training": {
20 "examples": 6,
21 "procedure": "token-overlap-counts-v1"
22 }
23}
24receipt sha256: 957f864a4562The Bitter Lesson isn't "throw compute at every problem." It's a test for your design:
Language models begin by converting raw text into discrete pieces. Tokenizers are a perfect next place to ask the same question: which reusable units can be learned from data, and what mistakes are introduced at that boundary?
Answer every question, then check your score. Score above 75% to mark this lesson complete.
9 questions remaining.
The Bitter Lesson.
Sutton, R. S. · 2019
Scaling LLM Test-Time Compute Optimally Can Be More Effective Than Scaling Model Parameters.
Snell, C., et al. · 2024 · arXiv preprint
Scaling Laws for Neural Language Models
Kaplan et al. · 2020
Training Compute-Optimal Large Language Models.
Hoffmann, J., et al. · 2022 · NeurIPS 2022
Mastering Chess and Shogi by Self-Play with a General Reinforcement Learning Algorithm.
Silver, D., et al. · 2017 · arXiv preprint
Questions and insights from fellow learners.