Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

CODING CHALLENGE · N°76

Ship or Stop

Medium AI EngineeringAgentsEvaluationSelf-Improvement

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".

SOLVE IT YOURSELF

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.

YOUR TASK

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": [...]}.

CPython · WebAssembly
Examples and constraints 5 examples
EXAMPLE 1
Input 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}
Output {'ship': True, 'stops': []}
nothing fired: the humans confirmed the verifier gain, and the canary is big enough and ahead — the only shape that ships
EXAMPLE 2
Input same prev and canary; curr = prev with verifier_pass 0.74, human_pass 0.49
Output {'ship': False, 'stops': ['reward-hacking']}
the number you control moved 0.13 and the one you do not moved 0.01
EXAMPLE 3
Input same prev and canary; curr = prev with frozen 0.539
Output {'ship': False, 'stops': ['frozen-set-down']}
no tolerance at all — one tenth of a point down is a stop
EXAMPLE 4
Input same prev; curr = prev; canary = {"runs": 20, "wins": 11, "min_runs": 200}
Output {'ship': False, 'stops': ['canary-too-small']}
ahead, and on far too few runs to know it — read it early and the noise will flatter you eventually
EXAMPLE 5
Input same prev; curr = prev; canary = {"runs": 0, "wins": 0, "min_runs": 200}
Output {'ship': False, 'stops': ['canary-too-small']}
edge case: a canary that has not started reports its size instead of dividing by zero
CONSTRAINTS
  • reward-hacking: curr["verifier_pass"] - prev["verifier_pass"] > 0.01 and the same delta for human_pass is <= 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_rows up) and memory_hit_rate did not (<= the previous one).
  • unrollable: curr["can_roll_back"] is falsey.
  • unreproducible: curr["weights_updated"] is truthy and curr["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
HINTS — 5 IDEAS
  1. 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.
  2. 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.
  3. frozen-set-down uses a bare `<`. An unchanged frozen set is not a drop, and there is no "significant" threshold to apply.
  4. unreproducible is guarded by weights_updated. A round that only changed a prompt cannot fire it, however little paperwork it left.
  5. 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.

1 reward-hacking+0.13 vs +0.012 frozen-set-down0.51 < 0.5403 diversity-collapse2.90, not < 2.324 memory-bloatrows 1200 = 12005 unrollablerollback: False6 unreproducibleno weights changed7 canary-too-small5 runs < 2008 canary-not-aheadskipped by elifchecks 7 and 8 are an if/elif: a too-small canary is never dividedship = False, stops = [reward-hacking, frozen-set-down,unrollable, canary-too-small], in check order
The walkthrough round. Highlighted checks fired and appended their name; the dashed one never ran. The output is simply the highlighted names read in check order, which is why the list never needs sorting.

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.

stepcheckvaluesresult
1reward-hackingd_verifier = 0.13 > 0.01, d_human = 0.01 ≤ 0.065append
2frozen-set-down0.51 < 0.540append
3diversity-collapse2.90 < 2.90 × 0.8 = 2.32 is falseno
4memory-bloatrows 1200 > 1200 is falseno
5unrollablecan_roll_back is Falseappend
6unreproducibleweights_updated is Falseno
7canary-too-small5 < 200append
8canary-not-aheadskipped by the elif, though 5 of 5 is a 100% win rateno; 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-small instead of raising, because the size check runs before the division. The guard relies on min_runs being positive: with {"runs": 0, "wins": 0, "min_runs": 0} the reference raises ZeroDivisionError.
  • Floating-point deltas decide exact boundaries. 0.62 - 0.61 is 0.010000000000000009, so a verifier gain of “exactly 0.01” fires reward-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 is wins / runs <= 0.5.
  • Diversity at exactly 80% does not fire: 2.90 * 0.8 is 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 / runs before checking runs, so a canary that has not started yet raises ZeroDivisionError.
  • Firing unreproducible when 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 curr with prev asks 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.

Finished this one? 0 / 76 Challenges done
NextDesign an LLM Inference Server Next in “Become an AI Engineer” · step 18 of 19

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More Challenges