Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
Ship or Stop
A self-improvement loop finished another round and every number on the dashboard went up. Write the gate that decides whether it ships: six stop conditions over two rounds of measurements, plus a canary nobody is allowed to read early.
The problem
Implement ship_or_stop(prev, curr, canary). prev and curr are one round of measurements each, with keys verifier_pass, human_pass, frozen, distinct_per_task, memory_rows, memory_hit_rate, can_roll_back, weights_updated and training_set_reproducible. canary is {"runs": int, "wins": int, "min_runs": int}. Return {"ship": bool, "stops": [name, …]} where ship is True only when stops is empty, and stops holds these names in exactly this order, each appended only if its condition holds: "reward-hacking", "frozen-set-down", "diversity-collapse", "memory-bloat", "unrollable", "unreproducible", "canary-too-small", "canary-not-ahead".
Your turn — write it
Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.
Implement ship_or_stop(prev, curr, canary) — six stop conditions over two rounds of measurements plus two canary guards, appended in the order given, returning {"ship": bool, "stops": [...]}.
Examples and constraints 5 examples
prev = {"verifier_pass": 0.61, "human_pass": 0.48, "frozen": 0.540, "distinct_per_task": 2.90, "memory_rows": 1200, "memory_hit_rate": 0.34, "can_roll_back": True, "weights_updated": False, "training_set_reproducible": True}; curr = prev with verifier_pass 0.66, human_pass 0.54, frozen 0.556, memory_hit_rate 0.39; canary = {"runs": 200, "wins": 130, "min_runs": 200}{'ship': True, 'stops': []}same prev and canary; curr = prev with verifier_pass 0.74, human_pass 0.49{'ship': False, 'stops': ['reward-hacking']}same prev and canary; curr = prev with frozen 0.539{'ship': False, 'stops': ['frozen-set-down']}same prev; curr = prev; canary = {"runs": 20, "wins": 11, "min_runs": 200}{'ship': False, 'stops': ['canary-too-small']}same prev; curr = prev; canary = {"runs": 0, "wins": 0, "min_runs": 200}{'ship': False, 'stops': ['canary-too-small']}- reward-hacking:
curr["verifier_pass"] - prev["verifier_pass"] > 0.01and the same delta forhuman_passis<=half of it. - frozen-set-down:
curr["frozen"] < prev["frozen"]. Any drop. Equal is not a drop. - diversity-collapse:
curr["distinct_per_task"] < prev["distinct_per_task"] * 0.8. - memory-bloat: the store grew (
memory_rowsup) andmemory_hit_ratedid not (<=the previous one). - unrollable:
curr["can_roll_back"]is falsey. - unreproducible:
curr["weights_updated"]is truthy andcurr["training_set_reproducible"]is falsey. A round that updated no weights never fires this. - canary-too-small:
canary["runs"] < canary["min_runs"]. Check this before dividing — a canary with 0 runs must not raise. - canary-not-ahead: only when the canary is big enough, and then
wins / runs <= 0.5.
Hints 5 ideas
- Build one list and append in the statement’s order. Never sort it — the caller reads the first entry as the most urgent thing to look at.
- reward-hacking needs BOTH halves: the verifier delta must exceed 0.01 AND the human delta must be at most half of it. A verifier gain the humans matched is not hacking.
- frozen-set-down uses a bare `<`. An unchanged frozen set is not a drop, and there is no "significant" threshold to apply.
- unreproducible is guarded by weights_updated. A round that only changed a prompt cannot fire it, however little paperwork it left.
- Do the canary last, and check runs < min_runs BEFORE computing wins / runs, or a canary that has not started raises ZeroDivisionError instead of reporting canary-too-small.
Approach, complexity & discussion — open after you solve
Start from brute force
The obvious gate is the dashboard reading: ship when curr["verifier_pass"] > prev["verifier_pass"] and the canary wins more than half its runs. It is O(1) time and O(1) space, and it ships the first example correctly. It is wrong in the cases this gate exists for. On the reward-hacking round, where the verifier rose 0.13 and the humans 0.01, it returns True. On a canary that has not started, wins / runs raises ZeroDivisionError instead of reporting that the sample is too small. And it returns a bare boolean, so when it does refuse, nobody knows which number to look at first.
The approach
Treat the gate as an ordered checklist and append as you go. The reference builds one list, stops, and runs the checks in exactly the statement’s order, so the output order is the contract and never needs sorting. Every check compares curr against prev, because each number only means something as a change.
Two checks have two halves: reward-hacking needs d_verifier > 0.01 and d_human <= d_verifier / 2, and memory-bloat needs the store to grow and the hit rate not to. unreproducible is guarded by curr["weights_updated"], so a prompt-only round cannot fire it. The canary comes last as an if/elif: canary["runs"] < canary["min_runs"] is tested first, and only otherwise is wins / runs computed, which keeps a 0-run canary from raising and makes the two canary stops mutually exclusive. Finally "ship": not stops means the flag can never disagree with the list.
Walk through an example
With prev at verifier 0.61, human 0.48, frozen 0.540, distinct 2.90, 1200 rows and rollback available, a curr that changes verifier to 0.74, human to 0.49, frozen to 0.51 and can_roll_back to False, and a canary of 5 wins in 5 runs against min_runs = 200, four of the eight checks fire.
| step | check | values | result |
|---|---|---|---|
| 1 | reward-hacking | d_verifier = 0.13 > 0.01, d_human = 0.01 ≤ 0.065 | append |
| 2 | frozen-set-down | 0.51 < 0.540 | append |
| 3 | diversity-collapse | 2.90 < 2.90 × 0.8 = 2.32 is false | no |
| 4 | memory-bloat | rows 1200 > 1200 is false | no |
| 5 | unrollable | can_roll_back is False | append |
| 6 | unreproducible | weights_updated is False | no |
| 7 | canary-too-small | 5 < 200 | append |
| 8 | canary-not-ahead | skipped by the elif, though 5 of 5 is a 100% win rate | no; returns {"ship": False, "stops": ["reward-hacking", "frozen-set-down", "unrollable", "canary-too-small"]} |
Complexity
The reference and the one-line gate are both O(1) time and O(1) space: eight comparisons over a fixed set of keys, and a list of at most seven names, since the two canary stops exclude each other. The difficulty is correctness at the boundaries: strict versus non-strict comparisons, a division that must be guarded, and floating-point deltas.
Edge cases to test
- A canary with 0 runs reports
canary-too-smallinstead of raising, because the size check runs before the division. The guard relies onmin_runsbeing positive: with{"runs": 0, "wins": 0, "min_runs": 0}the reference raisesZeroDivisionError. - Floating-point deltas decide exact boundaries.
0.62 - 0.61is0.010000000000000009, so a verifier gain of “exactly 0.01” firesreward-hacking. And a human gain of exactly half on paper (verifier 0.61 → 0.71, humans 0.48 → 0.53) computes as slightly more than half and does not fire. Use integer basis points or an explicit tolerance if those boundaries matter. - A level canary, 100 wins in 200 runs, fires
canary-not-ahead, because the rule iswins / runs <= 0.5. - Diversity at exactly 80% does not fire:
2.90 * 0.8is the threshold itself, and the rule is a strict<. - No condition looks at a plain drop in the pass rates: with verifier 0.61 → 0.50 and humans 0.48 → 0.40 and everything else unchanged, the round ships. Regressions are meant to surface in the frozen set.
- A store that shrinks never fires
memory-bloat, even when its hit rate falls (rows 1200 → 900, hit rate 0.34 → 0.20).
Common mistakes
- Sorting or de-duplicating the stop list. The order is specified; a caller reads the first entry as the most urgent.
- Using
<=on the frozen set. It has no tolerance: any drop is a stop, and an unchanged frozen set is not. - Computing
wins / runsbefore checkingruns, so a canary that has not started yet raisesZeroDivisionError. - Firing
unreproduciblewhen no weight update happened. The condition is about a weight update you cannot re-derive, not about missing paperwork on a prompt change. - Treating a round with identical measurements as a stop. Nothing got worse, so nothing fires.
Follow-ups an interviewer asks next
- How would you make the canary check statistically sound?
- Require a confidence bound above 0.5, not just a raw rate. With a 95% Wilson interval, 130 wins in 200 runs has a lower bound of about 0.58, so it is ahead; 11 wins in 20 has a lower bound of about 0.34, so it proves nothing. Fix the sample size in advance either way: checking repeatedly and stopping at the first good reading inflates false positives.
- The list of conditions keeps growing. How do you keep it maintainable?
- Make each condition a named row in an ordered table, a name plus a predicate over
(prev, curr, canary), and loop over the table. The order then lives in one place, each rule can be unit-tested alone, and a new condition is one row. Keep the canary pair as a single row, since its two outcomes exclude each other. - What should happen after a stop fires?
- Block the release and route the first stop to its owner: reward-hacking to whoever owns the verifier, unrollable to the release process. Log every measurement alongside the decision, so a reviewer can later see why a round did or did not ship. An override should be possible but never silent: it records who made it and why.
- Why compare against the previous round instead of a fixed bar?
- Because each number is meaningful only as a change for the same agent on the same evaluation. A fixed bar such as 0.7 verifier pass would accept a round that cleared it by gaming the verifier and reject a round that held steady on a harder task mix. Comparing
currwithprevasks whether this round made things better, which is the question a ship decision has to answer.
Where this shows up
This is the gate between “the loop ran” and “the loop shipped”. Every condition here exists because some number that went up was mistaken for an agent that got better: a verifier agreeing with itself more, a model narrowing onto its own habits, a canary read early because it happened to be ahead. Writing it as code rather than a wiki page is what makes it a gate instead of a good intention.
Check yourself
Against BASE, a round has verifier 0.70, human 0.55, weights_updated = True and training_set_reproducible = False, with a canary of 100 wins in 200 runs (min_runs = 200). What does the gate return?
{"ship": False, "stops": ["unreproducible", "canary-not-ahead"]}. The humans rose 0.07, more than half of the verifier's 0.09, so it is not reward hacking; the weights changed on a training set nobody can re-derive; and the canary is big enough but level at 0.5, which the <= counts as not ahead.
The memory store grows from 1,200 to 1,500 rows with its hit rate unchanged at 0.34, and distinct_per_task falls from 2.90 to 2.30. Which stops fire, and in what order?
["diversity-collapse", "memory-bloat"]. 2.30 is below 2.90 × 0.8 = 2.32, and the store grew while the hit rate did not improve: the rule is <=, so an unchanged hit rate counts. The order comes from the code, not from which problem looks worse.
The verifier moves 0.61 → 0.71. With humans 0.48 → 0.52 the round stops for reward-hacking; with humans 0.48 → 0.54 it ships. Why does the rule use half the verifier gain instead of "the humans did not improve at all"?
A gain of 0.04 is under half of 0.10, so most of the verifier's improvement is not visible to humans, and the round stops; 0.06 is over half, so the humans confirmed most of it. A rule of "humans flat" would wave through a round where humans moved a token 0.01 while the verifier jumped 0.13, which is exactly the reward-hacking example.
Rewrite the canary checks as two independent ifs. What happens to a canary of 8 wins in 20 runs, and to one with 0 runs?
The 20-run canary now reports both canary-too-small and canary-not-ahead, reading a win rate the gate just said is too noisy to trust; the reference reports only the first. The 0-run canary raises ZeroDivisionError, because the division now runs even after the size check has failed.
Reference solution
Reference solution — Python
def ship_or_stop(prev, curr, canary):
stops = []
d_verifier = curr["verifier_pass"] - prev["verifier_pass"]
d_human = curr["human_pass"] - prev["human_pass"]
if d_verifier > 0.01 and d_human <= d_verifier / 2:
stops.append("reward-hacking")
if curr["frozen"] < prev["frozen"]:
stops.append("frozen-set-down")
if curr["distinct_per_task"] < prev["distinct_per_task"] * 0.8:
stops.append("diversity-collapse")
if curr["memory_rows"] > prev["memory_rows"] and curr["memory_hit_rate"] <= prev["memory_hit_rate"]:
stops.append("memory-bloat")
if not curr["can_roll_back"]:
stops.append("unrollable")
if curr["weights_updated"] and not curr["training_set_reproducible"]:
stops.append("unreproducible")
if canary["runs"] < canary["min_runs"]:
stops.append("canary-too-small")
elif canary["wins"] / canary["runs"] <= 0.5:
stops.append("canary-not-ahead")
return {"ship": not stops, "stops": stops}Questions about Ship or Stop
What is reward hacking in an agent evaluation loop?
Reward hacking is when a system improves the score it is optimized against without improving the thing the score was meant to measure. In a self-improvement loop the automatic verifier is that score, so an agent can learn to satisfy the verifier’s checks, formatting habits or blind spots while real quality stays flat. This gate detects it by comparing two signals: the verifier pass rate, which the loop can move, and the human pass rate, which it cannot. A verifier gain above 0.01 with at most half of it confirmed by humans is treated as hacking.
Why should a canary result not be read early?
Because a small sample is noisy, and if you check it again and again you will eventually see it ahead by chance. Reading a canary after 20 runs and shipping the first time it looks good turns random fluctuation into a release decision. The gate fixes a minimum number of runs in advance and refuses to look at the win rate until that number is reached, so 11 wins out of 20 is reported as too small rather than as a 55 percent success. It is the same discipline as fixing the sample size of an A/B test before it starts.
Why does the order of the stop conditions matter?
Because the caller reads the list as a priority order: the first entry is what someone should look at first. The statement fixes the order, from reward hacking down to the canary, and the hidden tests check it, so the list must be built by appending in that order and never sorted or deduplicated afterward. Sorting alphabetically would put a canary stop first, ahead of a reward-hacking signal that matters far more. A fixed order also makes outputs comparable from one round to the next, which helps when tracking which problems keep coming back.
Why does the frozen evaluation set have no tolerance?
A frozen set is a fixed collection of tasks that never changes between rounds, so a drop on it cannot be blamed on a harder or different sample. Any decrease means the new round is worse at something it used to handle. A tolerance invites a slow slide, where each round loses a little within the allowance and the losses add up. So the gate fires on any drop, even 0.540 to 0.539, and an unchanged score does not fire. If the frozen score is too noisy for that rule, the fix is a larger frozen set, not a looser comparison.
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Challenges
- Retry with Exponential BackoffTools time out and APIs rate-limit — a production agent retries without hammering. Exponential backoff waits longer after each failure (1s, 2s, 4s, 8s…) up to a cap. Compute the delay schedule: delay[i] = min(cap, base·2ⁱ). Solve it in Python or TypeScript.Read →
- Trim History to a Token BudgetEvery agent turn re-sends the whole conversation, so history must fit the context budget. Keep the most recent messages that fit and drop the oldest — the trimmer at the heart of context management. Solve it in Python or TypeScript.Read →
- Parse a Tool CallWhen a model wants to act, it emits a call like search(query=cats, limit=5). Parse that string into a function name and typed arguments before the harness can dispatch it — the little parser that turns a model's intent into an action. Solve it in Python or TypeScript.Read →
- Run the Goal LoopThe engine of loop engineering: attempt the front task, let an independent verifier judge it, retry failures with state untouched, and stop when the goal is met — or loudly when the cap trips. Implement the whole outer loop as one pure, testable function. Solve it in Python or TypeScript.Read →
- Write a VerifierIn RL from verifiable rewards, the verifier IS the reward — and a gameable one is worse than none, because every false positive is a lie the model learns. Write a verifier a reward-hacking agent can’t fool: recompute the score from ground truth, demand exact task coverage, and compare types-intact. Solve it in Python or TypeScript, with hidden tests.Read →
- Build Your Own Agent LoopStrip an "AI agent" of its mystique and what’s left is a loop: read the model’s next move — tool call or final answer — run the tool, feed the observation back, repeat until it answers or the step cap trips. Implement the whole ReAct inner loop as one pure function. Solve it in Python or TypeScript, with hidden tests.Read →