Eval report · Route Gen AI
A cyclist types “45 miles from Cross Plains, through Black Earth, under 2000 feet of climbing, and off Highway 14” and gets back a Garmin-ready route. The interesting question is not whether something comes back — it is whether the thing that comes back satisfies the sentence. This report measures that, case by case, with computed checks on the GPX file rather than by asking a model how it did.
Run postfix · request set routegen-eval-v1, frozen 2026-09-19 · finished 2026-09-20T11:03:26 · every number on this page is read from the saved results file linked at the bottom.
The product
Route Gen AI turns a plain-English ride request into ranked, Garmin-importable GPX routes on a map, and then lets you edit them by talking to them — “avoid Whitney Way”, “make it ten miles longer”, “add a stop at Olbrich”. It exists because route planners make you draw, and drawing a 40-mile loop that avoids one particular road is tedious in a way a sentence is not.
The technically interesting part is not the parsing. A language model reads “under 2000 feet of climbing” correctly on the first try. The hard part is that a route is a geometric object with physical constraints, and language models cannot be trusted to produce one. So the architecture puts the model in exactly one place, at the front, translating intent — and everything after it is ordinary deterministic code that generates candidates and throws away the ones that do not measure up.
That split is also what makes this evaluable. Because the acceptance criteria are computed, the eval can use the same kind of computation to check them — independently, from the file on disk.
How it works
The outlined box is the only step a language model touches. Steps 2–5 are deterministic, run offline in tests, and are where every claim in this report is verified.
Why it is built this way: a hostile or confused request can only produce weird-but-schema-valid values, which the clamp bounds — it can never produce geometry. The blast radius of a bad parse is one wrong number, not a route through a lake.
Demonstration
These are unedited runs from the campaign below. Each map is drawn from the GPX file the app wrote; the measurements next to it were recomputed from that file by the eval harness, not read back from the app's own report of itself. The GPX links are the real files.
Featured because they span the product's range, not because they scored well: one floor case, one hard constraint, one waypoint case, and the most constrained request in the set — which is held out. Every case, including the failures, is in the full table at the bottom.
“give me an easy 12 mile loop from Monona Terrace, Madison WI”
Measured independently from the GPX on disk, not read back from the app. Generated in 12 s.
The floor case: a short loop from a public landmark. Featured because it is the simplest thing the product claims to do.
“30 mile ride from Monona Terrace Madison WI with less than 1000 feet of climbing”
Measured independently from the GPX on disk, not read back from the app. Generated in 17 s.
A stated climb ceiling. Featured because it is the one constraint the ranker enforces by rejecting candidates outright.
“35 miles from Middleton WI, I want it to pass through Cross Plains and Black Earth”
Measured independently from the GPX on disk, not read back from the app. Generated in 10 s.
Two named towns in ride order. Featured because multi-waypoint loops use a different generator than plain bearing loops.
“I want 45 miles from Cross Plains WI, through Black Earth, under 2000 feet of climbing, and off Highway 14”
Measured independently from the GPX on disk, not read back from the app. Generated in 7 s.
Held out. Distance + waypoint + climb cap + avoid in one sentence -- the most constrained request in the set.
Method
62 requests were written as a versioned set (routegen-eval-v1) with expectations frozen on 2026-09-19. They are split five ways on purpose: straightforward asks, hard combinations, ambiguous asks, requests the system should decline, and requests naming features the product does not have.
Each case is graded on three separate questions, because conflating them hides where a failure actually is:
Checks are marked hard (a promise the product makes, and a failure turns the case red) or soft (a preference it tries to honor, and a failure makes the case amber). Nothing is graded by a model. In particular, the model that produced the route is never asked whether the route is good.
| Check | Limit | Reason |
|---|---|---|
| distance | 0.15 | The product's own acceptance band: routes/scoring.py rejects any candidate more than 15% from the target, so 15% is the promise being tested, not a number chosen to flatter the result. Edits use 18% because an edit re-routes around fixed endpoints and has less freedom to hit a length exactly. |
| loop_closure_m | 250.0 | routes/editing._is_loop uses 250 m. Wider than it looks: a loop that starts and ends on opposite sides of a divided road is closed for a rider. |
| start_proximity_m | 600.0 | Geocoding a landmark returns a building centroid; the router snaps to the nearest routable way. 600 m absorbs both without hiding a route that starts in the wrong place. |
| via_m | 2000.0 | BRouterProvider.VIA_NEAR_M -- the app's own definition of 'the route goes through there'. A town is an area, and forcing the geocoded centroid creates touch-and-retreat tendrils. Tightened per-case to 1500 m for a specific landmark. |
| avoid_on_road_m | 200.0 | Measured as meters ridden within 28 m of the named road's real OSM centerline (the same measure routes/road_avoid.py uses to verify an avoid edit). Under 200 m is a crossing or a forced first/last block, not riding the road. |
| teleport | gap > 800 m AND a bearing change of 60 deg or more | A first attempt used a plain 600 m gap threshold and flagged three perfectly good routes: Wisconsin's section-line grid gives dead-straight mile-long roads, which BRouter emits as a single segment. Requiring the long segment to ALSO turn away from the route's heading keeps the check able to catch a real splice without punishing straight rural geometry. Recorded here because the threshold was wrong before it was right. |
| repeat_frac | 0.25 | routes/scoring.py rejects loops above 25% repeated road. Reported for every route; only enforced on loops, because an out-and-back is 100% repeated BY DESIGN and flagging that would be a scorer bug, not a product bug. |
| climb_cap_slack | 0.0 | A stated ceiling is a hard promise: routes/scoring.py rejects anything above it, so the eval does too, with no slack. The elevation figure itself carries roughly +/-10% instrument spread (see PROVENANCE), which is reported alongside. |
What this does not establish. Whether a route is SAFE or PLEASANT to ride. No check in this harness establishes that. Traffic volume, shoulder width, pavement quality, sightlines and seasonal conditions are outside what OSM tags and this eval can see. Those claims would need field riding. Every route in this report passed software checks. None of them was ridden as part of this campaign.
Results
Development and held-out sets are reported separately, and live end-to-end runs are reported separately from the offline failure-injection cases — a passing mock says nothing about a real router.
| Tier | What runs | Result |
|---|---|---|
| Offline | Mocked HTTP and deliberate failure injection: routing provider unreachable, geocoder unreachable. Plus the repository's unit suite — no keys, no server. | 2/2 eval cases 152 unit tests |
| Live end-to-end | Real model parse, real self-hosted BRouter, real Nominatim and Overpass, real GPX written to disk and re-measured. The only tier that is evidence about the product end to end. | 56/60 pass , 1 partial , 3 fail |
| HTTP end-to-end | hostile / boundary inputs, new user: generate a route, edit chain, upload a foreign gpx (lon-first attrs, rtept, extra junk), cancel + concurrency, interval spot driven against the running web app, covering what the in-process harness cannot see: status codes, path traversal, per-session isolation, rate limits, mid-job cancellation, upload laundering. | 50 checks, 0 issue(s) |
This run: 34 live parses, 82,743 input + 3,105 output tokens on claude-haiku-4-5. Routing, elevation, geocoding and road geometry cost nothing: BRouter is self-hosted, and Nominatim and Overpass are free public OSM services.
| Run | Live parses | Model spend |
|---|---|---|
| baseline | 59 | $0.1578 |
| postfix | 34 | $0.0983 |
| postfix (variability) | 45 | $0.1330 |
| smoke | 1 | $0.0027 |
| Campaign total | — | $0.44 |
Metered = what the harness recorded per run. Distinct parses = every unique request text ever sent, which also covers runs that were interrupted before writing a meta file. The campaign total adds the variability repeats back on top of the distinct-parse figure and is rounded up.
Variability
Everything after the parse is deterministic, so the only place run-to-run variation can enter is the one model call. To find out how much, 9 requests were parsed 5 times each with the cache disabled — a mix of crisply specified asks as a control and deliberately vague ones.
The reproducible number is the one about constraints — request type, distance, shape, climb cap, how many waypoints and avoid-places. Those are the fields that decide whether a candidate is accepted. 5 of 9 were identical on all of them across all five runs, and the split is not subtle: every crisply specified request was stable, and every unstable one was a request that genuinely underspecifies the ride or depends on session state.
Below that level, wording moves constantly and mostly harmlessly — the same start is written “Monona Terrace Madison WI” one run and “Monona Terrace, Madison, WI” the next, which geocodes identically. Only 2 of 9 drifted far enough to change the graded outcome. Free-text notes are excluded throughout: the model rewords those every run and they do not affect what gets generated.
| Case | Request | Constraints | Every spec produced across 5 runs | Outcome |
|---|---|---|---|---|
| D01 | give me an easy 12 mile loop from Monona Terrace, Madison WI | stable | type=route, start='Monona Terrace, Madison WI', 12 mi, loop type=route, start='Monona Terrace, Madison, WI', 12 mi, loop type=route, start='Monona Terrace, Madison, WI', 12 mi, loop, minimize climb | same verdict |
| D10 | 30 mile ride from Monona Terrace Madison WI with less than 1000 feet of climbing | stable | type=route, start='Monona Terrace Madison WI', 30 mi, cap 1000 ft, both type=route, start='Monona Terrace, Madison, WI', 30 mi, cap 1000 ft, both, minimize climb | same verdict |
| D15 | I hate riding on Whitney Way -- 22 mile loop from the Memorial Union Madison WI without it | stable | type=route, start='Memorial Union Madison WI', 22 mi, loop, avoid 'Whitney Way Madison WI' type=route, start='Memorial Union, Madison, WI', 22 mi, loop, avoid 'Whitney Way, Madison, WI' | same verdict |
| D20 | I want to go for a bike ride | 4 versions | type=route, 0 mi, both type=route, 10 mi, both type=route, 15 mi, both type=route, 20 mi, both | flips |
| D21 | a short easy spin from Olin Park Madison WI | 2 versions | type=route, start='Olin Park Madison WI', 10 mi, cap 300 ft, loop, minimize climb type=route, start='Olin Park, Madison, WI', 10 mi, cap 300 ft, loop, minimize climb type=route, start='Olin Park, Madison, WI', 10 mi, cap 500 ft, loop, minimize climb | same verdict |
| H10 | I want 45 miles from Cross Plains WI, through Black Earth, under 2000 feet of climbing, and off Highway 14 | stable | type=route, start='Cross Plains, WI', 45 mi, cap 2000 ft, both, via 'Black Earth, WI', avoid 'Highway 14' type=route, start='Cross Plains, WI', 45 mi, cap 2000 ft, both, via 'Black Earth, WI', avoid 'Highway 14, Wisconsin' | same verdict |
| H13 | ride from Olin Park Madison WI, I've got maybe an hour but I might have longer | 3 versions | type=route, start='Olin Park Madison WI', 16 mi, both type=route, start='Olin Park Madison WI', 16 mi, loop type=route, start='Olin Park, Madison, WI', 10 mi, loop | same verdict |
| H23 | route me through Vilas Park and then the Arboretum | 2 versions | type=edit_route, mode=via, places=['Vilas Park, Madison, Wisconsin', 'Arboretum, Madison, Wisconsin'] type=edit_route, mode=via, places=['Vilas Park, Madison, Wisconsin', 'University of Wisconsin Arboretum, Madison, Wisconsin'] type=route, 15 mi, loop, via 'Vilas Park, Madison, Wisconsin', via 'University of Wisconsin–Madison Arboretum, Madison, Wisconsin' | flips |
So the honest reading of the headline number is this: a crisply specified request is reproducible, and a vague one is a distribution rather than an answer. “I want to go for a bike ride” produced four different distances across five runs, including one of zero miles that the server-side clamp caught. The product has no way to ask a clarifying question — it has to guess, and it does not tell the rider it guessed. That is a product gap this campaign measured rather than fixed, and it is the reason a single pass/fail count would overstate what is known here.
Engineering
The campaign found four distinct defects. This one is worth walking through because the failure was total — the rider got no route at all for a request the product fully supports — and because the held-out set contained the same bug pointing the other way.
Case D15 is an ordinary sentence: “I hate riding on Whitney Way — 22 mile loop from the Memorial Union Madison WI without it.” It asks for a new ride and gives a length, a start, and one road to stay off. The app came back with a refusal and no route at all.
The parse returned request_type: “edit_route”, mode avoid, place Whitney Way — and dropped distance_miles entirely, because the edit branch of the schema has no such field. The service then looked for a current route to edit, found none, and said so. Every layer below the parse behaved correctly. The request was classified wrong one step earlier, and the 22 miles the rider asked for never survived the first hop.
The system prompt introduced the avoid edit mode with the example “I don't like riding Y” — almost exactly D15's opening clause. Nothing in the prompt said which signal decides between a new route and an edit, so a sentence carrying both a complaint and a complete route spec resolved toward whichever example it matched first.
The held-out set contained the mirror image. H23, “route me through Vilas Park and then the Arboretum”, states no length, so it is plainly an edit — but it parsed as a new route with an invented 10-mile distance, and the app replaced the rider's 18-mile route with a 10-mile one they never asked for. The next request in that session, a bare “undo” (H24), then had nothing to step back to. One misread sentence destroyed the work and the way back to it.
One rule at the top of the prompt, built on the only signal that actually separates the two cases: whether a length for the whole ride is stated. A stated length means a new route, even when the sentence also names roads to avoid or places to pass through. No stated length means an edit — with an explicit instruction never to invent a distance in order to treat it as a new route.
This is where a report that only reported wins would stop. D15 is fixed and stable: parsed five more times with the cache disabled, it came back as a route with the avoid constraint intact every time, and the app now returns the 22-mile loop it always should have. H23 is not fixed. Parsed five times, it classified correctly three times and reverted to inventing a distance twice — so on the campaign run it failed, and H24 cascaded behind it. One prompt rule moved a reliable failure to a coin flip in that direction. That is an improvement and it is not a fix, and the page reports it as both.
No current route to edit — compose or upload one first.
5 route(s) — best: #1 loop: 22.0 mi, 881 ft, 1% repeat
2 route(s) — best: #1 loop: 9.9 mi, 380 ft, 1% repeat
6 route(s) — best: #1 loop: 13.9 mi, 560 ft, 2% repeat
Nothing to undo — this is the earliest version.
Nothing to undo — this is the earliest version.
Two things this does NOT claim. First, the rule was written against D15, a development-set case, so D15 passing is not independent evidence — the held-out cases are, and they say the rule generalizes only partly. Second, nothing here was tuned on H23 or H24: their expectations were frozen before the rule existed and neither was used to shape it, which is exactly why they can report a partial result instead of a flattering one. The repository also grew six parse-regression cases covering both directions of the boundary, so a future prompt edit cannot quietly undo the half that does work.
The remaining exposure is the interesting part. The parser cannot know whether a current route exists — that is session state the service holds, not something in the sentence. So the durable fix is not a better prompt at all: it is to make the mistake survivable, by recording a newly generated route's parent so undo can step back to the ride the rider had. That is designed and not built, deliberately: the only evidence for it in this campaign sits in the held-out set, and spending that evidence to chase one more green row would cost the one clean measurement this campaign has.
The other three
Three of the four were the same shape, which is the part worth noticing. None of them was a wrong computation. In each case the program had already worked out the truth — that the router was down, that the lookup failed, that the stretch needs two laps — and then handed the user a sentence that did not contain it. A correct internal state and a misleading message is indistinguishable from a bug to the person using the thing, and no unit test asserting on return values would have caught any of them. They were found by writing down what the user should see and then checking the user-visible string against it.
With the routing server unreachable, the progress log said ROUTING SERVER UNREACHABLE four times while the banner said “No route met the constraints — try a looser target or different distance.” The diagnosis existed; it never reached the person reading the screen, and it sent them to fix a request that was never the problem. The provider now records that the server never answered, and a one-second TCP preflight means a black-holed router costs a second rather than a dozen two-minute timeouts.
No route met the constraints — try a looser target or different distance.
The routing server isn't responding, so no route could be generated. Nothing is wrong with your request — this is the router (BRouter) being down, and it will work again once the server is back.
An unreachable Nominatim raised requests.ConnectionError straight out of the service layer: a traceback on the CLI, and in the web app a banner reading ERROR: ConnectionError: HTTPConnectionPool(host=…). A place lookup that fails is an ordinary outcome of a request, so it is now a sentence — and the two failure modes get different advice, because a service being down is temporary while a place that does not exist needs the rider to rephrase. The held-out H14, “through the place with the good pie”, is the second failure mode and was not used to design the fix.
ConnectionError: HTTPConnectionPool(host='127.0.0.1', port=9): Max retries exceeded with url: /search?q=Monona+Terrace%2C+Madison%2C+WI&format=json&limit=1 (Caused by NewConnectionError("HTTPConnection(host='127.0.0.1', port=9): Failed to establish a new connection: [WinError 10061] No connection could be made because the target machine actively refused it"))
Couldn't reach the address lookup service (OpenStreetMap Nominatim), so the request never got as far as routing. Nothing is wrong with what you asked for — try again in a moment. (GeocodeUnavailable)
A 2×20 threshold rep needs about 6.7 mi of road at the app's assumed pace. The search returned a 5.5 mi stretch — a legitimate answer, since lapping a short stretch is supported — and labelled it with no hint that it takes two laps and a turnaround per rep. The CLI had computed that lap count all along and printed it; the label the web user reads dropped it.
Found 3 interval spot(s) — best: #1: 5.6 mi @ +0.1%, 11 stops, 0.0 mi out
Found 3 interval spot(s) — best: #1: 5.6 mi @ +0.1%, 11 stops, 0.0 mi out — 2 laps per rep (turnaround each lap). A 20-minute flat rep needs about 6.7 mi of road.
Scoring the scorer
An eval can be wrong in the same ways the thing it measures can. Three checks in this harness produced false failures on real routes and were corrected before any product change — recorded here because a report that only lists the subject's mistakes is not a report worth trusting. Each correction is pinned by a test asserting both that the legitimate geometry passes and that the broken geometry it exists to catch still fails; every case carries its scoring history in the saved results.
| What went wrong | Why, and what replaced it |
|---|---|
| A 600 m gap threshold flagged three good routes. | Wisconsin's section-line grid gives dead-straight half-mile and mile-long roads, which the router emits as a single segment. Raw gap length is not evidence of a break. |
| Requiring a sharp turn at one end still flagged grid corners. | You turn 90° onto a section road and then run straight for half a mile: entered with a turn, left without one. The check now requires the long segment to be off-axis at both ends, which a corner never is and a splice always is. |
| The out-and-back detector scored an exact out-and-back at 0.6%. | It resampled at a fixed step and compared by index, so the two halves drifted out of phase. Rewritten to compare positions by arc length. Until this was fixed the scorer was calling every out-and-back an accidental doubling-back. |
Before and after
Both columns are the same 62 cases, scored by the same rules. The development set is where the failures were diagnosed; the held-out set never informed a fix, so it is the honest read.
| Set | Before | After |
|---|---|---|
| Development (live) | 33/35 pass, 0 partial, 2 fail | 33/35 pass, 1 partial, 1 fail |
| Held out (live) | 22/25 pass, 0 partial, 3 fail | 23/25 pass, 0 partial, 2 fail |
| All live cases | 55/60 pass | 56/60 pass |
| Case | Set | Category | Was | Now | Had failed on |
|---|---|---|---|---|---|
| D15 | dev | avoid | fail | pass | outcome.succeeded, parse.request_type, parse.distance_miles, parse.avoid_places |
| D18 | dev | interval spot | fail | partial | spot.lap_count_disclosed |
| D21 | dev | ambiguous | pass | fail | — |
| D36 | dev | provider failure | fail | pass | outcome.message_names_cause |
| D37 | dev | provider failure | fail | pass | outcome.no_crash |
| H14 | heldout | ambiguous | fail | pass | outcome.no_crash |
Limitations
Every case
All 62 cases, with the measured outcome and the first hard check that failed. The featured demos above are four of these rows.
| Case | Request | Measured | Verdict |
|---|
| Case | Request | Verdict | Failed on |
|---|---|---|---|
| D18 | find me a flat stretch within 20 minutes of Olin Park Madison WI for 2x20 threshold intervals | partial | spot.long_enough_for_a_rep |
| D21 | a short easy spin from Olin Park Madison WI | fail | outcome.succeeded |
| H23 | route me through Vilas Park and then the Arboretum | fail | parse.request_type, parse.mode, parse.places_len_min |
| H24 | undo | fail | outcome.succeeded |
Reproducibility
The harness lives in the repository next to the app it tests. It drives routes.service.handle_request — the same entry point the web app and the CLI use — so a pass here is a pass for the shipped code path, not for a test double.
Parses are cached by request text, so re-running after a routing-side change costs nothing and scores the identical interpretation. Scoring is a pure function of the saved records, so python -m evals.rescore re-grades every run under the same rules when a tolerance changes.