Eval report · Route Gen AI

Describe a bike ride. Get a GPX that actually matches.

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.

56/60
live end-to-end cases fully passing every hard check, with 1 more passing every hard check but missing a soft preference.
23/25
of the held-out cases pass. Their expectations were frozen before any fix was written, and no held-out failure informed a change.
1.8%
median distance error against the requested mileage across 39 routes with a stated target; every one landed inside 10%. Worst case 9.7%.
$0.44
total model spend for the whole campaign, every run included. One cheap parse per request — routing, elevation, geocoding and map data are self-hosted or free.

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

What it does, and why the language part is the easy half

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

One model call, then arithmetic

1. ParseThe only model call. claude-haiku-4-5 with a JSON schema turns the sentence into a typed spec: distance, shape, climb cap, waypoints, avoid list. Enum-locked, no tools, no agent loop.
→
2. ClampEvery number the model emitted is bounded server-side. A schema-valid request can still say “300 miles”; the clamp pulls it to 150 and logs that it did.
→
3. GenerateSelf-hosted BRouter over OSM. Loops are synthesized by placing via-points on a circle through the start, swept across compass bearings and rescaled toward the target length.
→
4. Rank & rejectComputed, never judged: distance tolerance, climb ceiling, fraction of road ridden twice, meters on major highways. Rejections carry reasons.
→
5. GPXWritten as a GPX 1.1 track with elevation, importable to Garmin Connect as a course.

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

Four real requests and what came back

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.

Straightforward · case D01 · dev set pass

“give me an easy 12 mile loop from Monona Terrace, Madison WI”

12 miloop
Start2 mi84 ft range
↓ Download GPX
  • well-formed, 858 points, elevation present
  • 12.24 mi vs target 12 mi (+2.0%, tolerance +/-15%)
  • start-to-finish gap 0 m (limit 250 m)
  • begins 15 m from the geocoded start (limit 600 m)
  • continuous: no breaks. Longest single segment 269 m
  • 2% of the route rides the same road twice (the app rejects loops above 25%); mirror score 2% -- not an out-and-back

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.

Hard constraint · case D10 · dev set pass

“30 mile ride from Monona Terrace Madison WI with less than 1000 feet of climbing”

30 mibothclimb ≤ 1000 ftminimize climb
Start2 mi85 ft range
↓ Download GPX
  • well-formed, 1143 points, elevation present
  • 30.76 mi vs target 30 mi (+2.5%, tolerance +/-15%)
  • begins 15 m from the geocoded start (limit 600 m)
  • 961 ft vs cap 1000 ft (app model; independent unsmoothed sum 963 ft)
  • continuous: no breaks. Longest single segment 766 m

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.

Waypoints · case D13 · dev set pass

“35 miles from Middleton WI, I want it to pass through Cross Plains and Black Earth”

35 miloopthrough Cross Plains, WIthrough Black Earth, WI
StartCross Plains, WIBlack Earth, WI5 mi353 ft range
↓ Download GPX
  • well-formed, 1173 points, elevation present
  • 33.97 mi vs target 35 mi (+2.9%, tolerance +/-15%)
  • begins 23 m from the geocoded start (limit 600 m)
  • closest approach 17 m (limit 2500 m)
  • closest approach 3 m (limit 2500 m)
  • continuous: no breaks. Longest single segment 592 m
  • 22% of the route rides the same road twice (the app rejects loops above 25%); mirror score 45%

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.

Hardest combination · case H10 · heldout set pass

“I want 45 miles from Cross Plains WI, through Black Earth, under 2000 feet of climbing, and off Highway 14”

45 mibothclimb ≤ 2000 ftthrough Black Earth, Wisconsinavoid Highway 14, Wisconsin
StartBlack Earth, WI5 mi425 ft range
↓ Download GPX
  • well-formed, 2636 points, elevation present
  • 40.80 mi vs target 45 mi (+9.3%, tolerance +/-15%)
  • begins 17 m from the geocoded start (limit 600 m)
  • 1990 ft vs cap 2000 ft (app model; independent unsmoothed sum 1906 ft)
  • closest approach 475 m (limit 2500 m)
  • continuous: no breaks. Longest single segment 25 m
  • 0% of the route rides the same road twice (the app rejects loops above 25%); mirror score 0% -- not an out-and-back

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

What counts as a pass, decided before the run

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:

  • Did it understand? The parsed spec is compared field-by-field against the frozen expectation — distance in range, correct shape, correct edit mode, waypoints present.
  • Did it produce something valid? The GPX is re-parsed with a real XML parser (the app itself uses regexes) and checked for range-valid coordinates, finite elevations, continuity, and at least two points.
  • Does it satisfy the sentence? Distance measured by haversine over the geometry, loop closure, start proximity, closest approach to each requested waypoint, meters still ridden on a road the user excluded, climb against a stated ceiling.

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.

Tolerances, and why each one is that number
Chosen from the product's own thresholds where one exists, so the eval tests the promise rather than a flattering band.
CheckLimitReason
distance0.15The 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_m250.0routes/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_m600.0Geocoding 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_m2000.0BRouterProvider.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_m200.0Measured 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.
teleportgap > 800 m AND a bearing change of 60 deg or moreA 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_frac0.25routes/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_slack0.0A 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

Where it holds and where it does not

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.

Live end-to-end cases, by set
Real model parse, real self-hosted router, real geocoder, real GPX on disk
Which promise held, check by check
Every graded check across all 62 cases. A case can fail one and pass the rest, so these add up to more than the case counts — that is the point of grading them separately.
All cases by category
Green pass · amber passed every hard check but missed a soft preference · red hard-check failure · grey inconclusive
1.8%
median distance error across 39 live routes with a stated target; worst case 9.7%.
9.0s
median time to generate and rank a route end to end on a self-hosted router; p90 100.8 s, slowest 175.2 s.
2/2
offline failure-injection cases passing — routing provider down, geocoder down. Kept separate from the live numbers.
Three tiers, and what each one can prove
Mixing these would let a passing mock stand in for a real router
TierWhat runsResult
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.

RunLive parsesModel spend
baseline59$0.1578
postfix34$0.0983
postfix (variability)45$0.1330
smoke1$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

The same sentence, five times

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.

CaseRequestConstraints Every spec produced across 5 runsOutcome
D01give me an easy 12 mile loop from Monona Terrace, Madison WIstabletype=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
D1030 mile ride from Monona Terrace Madison WI with less than 1000 feet of climbingstabletype=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
D15I hate riding on Whitney Way -- 22 mile loop from the Memorial Union Madison WI without itstabletype=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
D20I want to go for a bike ride4 versionstype=route, 0 mi, both
type=route, 10 mi, both
type=route, 15 mi, both
type=route, 20 mi, both
flips
D21a short easy spin from Olin Park Madison WI2 versionstype=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
H10I want 45 miles from Cross Plains WI, through Black Earth, under 2000 feet of climbing, and off Highway 14stabletype=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
H13ride from Olin Park Madison WI, I've got maybe an hour but I might have longer3 versionstype=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
H23route me through Vilas Park and then the Arboretum2 versionstype=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

A new ride request, answered as an edit to a route that did not exist

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.

  1. The trigger

    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.

  2. What actually happened

    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.

  3. Root cause

    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.

  4. The same bug in the other direction, and worse

    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.

  5. The fix

    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.

  6. Did it work? Half of it did

    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.

FIRST decide "route" vs "edit_route", using the LENGTH OF THE WHOLE RIDE: - The request states a length or duration for the WHOLE ride ("30 miles", "a couple of hours", "45 mi loop") -> "route". It is still a "route" when it ALSO names roads to stay off or places to pass through; those belong in avoid_places / via_places. "I hate riding on X - give me a 22 mile loop from Y without it" is a ROUTE with avoid_places ["X"], NOT an avoid edit. - The request states no length for the whole ride and only describes a CHANGE ("go past X", "route me through X and then Y", "avoid Z", "make it longer", "end at W") -> "edit_route". NEVER invent distance_miles to turn such a request into a "route": replacing the rider's current route with a new one they did not ask for is the worst mistake you can make here.
Case D15 — I hate riding on Whitney Way -- 22 mile loop from the Memorial Union Madison WI without it
dev set · Dislike phrasing rather than the word 'avoid'.

Before · fail

No current route to edit — compose or upload one first.

After · pass

5 route(s) — best: #1 loop: 22.0 mi, 881 ft, 1% repeat

Case H23 — route me through Vilas Park and then the Arboretum
heldout set · Held out: a MULTI-waypoint via chain in one edit -- each place must arrive as its own geocodable string.

Before · fail

2 route(s) — best: #1 loop: 9.9 mi, 380 ft, 1% repeat

After · fail

6 route(s) — best: #1 loop: 13.9 mi, 560 ft, 2% repeat

Case H24 — undo
heldout set · Held out: bare one-word undo.

Before · fail

Nothing to undo — this is the earliest version.

After · fail

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

Correct internally, misleading on screen

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.

A dead router told the rider to change their request
case D36 · routes/service.py · routes/pipeline.py · routes/providers.py

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.

Case D36 — a 20 mile loop from Monona Terrace Madison WI
dev set · Routing provider unreachable. Must fail honestly and point at the routing server, not hand back an empty map or a traceback.

Before · fail

No route met the constraints — try a looser target or different distance.

After · pass

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.

A geocoder failure escaped as a stack trace
case D37 · routes/service.py

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.

Case D37 — a 20 mile loop from Monona Terrace Madison WI
dev set · Geocoder unreachable. Probes whether a dead Nominatim surfaces as a usable message or as an unhandled exception.

Before · fail

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

After · pass

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)

An interval stretch too short for the rep, presented without the caveat
case D18 · routes/service.py

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.

Case D18 — find me a flat stretch within 20 minutes of Olin Park Madison WI for 2x20 threshold intervals
dev set · The other request type entirely -- a stretch of road, not a route.

Before · fail

Found 3 interval spot(s) — best: #1: 5.6 mi @ +0.1%, 11 stops, 0.0 mi out

After · partial

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

Three checks that were wrong before they were right

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 wrongWhy, 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

What the fixes moved

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.

SetBeforeAfter
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
CaseSetCategoryWasNowHad failed on
D15devavoidfailpassoutcome.succeeded, parse.request_type, parse.distance_miles, parse.avoid_places
D18devinterval spotfailpartialspot.lap_count_disclosed
D21devambiguouspassfail—
D36devprovider failurefailpassoutcome.message_names_cause
D37devprovider failurefailpassoutcome.no_crash
H14heldoutambiguousfailpassoutcome.no_crash

Limitations

What is still unproven

  • No route in this campaign was ridden. Every claim here is about geometry and stated constraints. Whether a route is safe, pleasant, or has a shoulder is not something OSM tags and this harness can establish. The project's own history is field-testing — the devlog records 35 phases of riding routes and fixing what the ride exposed — but this report is not that evidence.
  • Elevation is not ground truth. SRTM-derived elevations carried in BRouter's GeoJSON output. The app's climb figure is routes/elevation.track_ascent, a model calibrated against RideWithGPS and one barometric .fit file; the devlog records that instruments themselves disagree by 8-16%, so +/-10% is the attainable accuracy. The eval reports the app's number AND an unsmoothed independent sum over the same elevations. Neither is ground truth -- only a barometric ride is.
  • One region. BRouter 1.7.10, self-hosted, profile fastbike-quiet, OSM extract tiles W90_N40 / W95_N40 (southern Wisconsin). Nothing here says the product works outside southern Wisconsin; other regions are a tile download, but that is a claim this campaign does not test.
  • Avoid-clearance is scored softly. A road at the start may be genuinely unavoidable, so meters-on-road is reported rather than gating the verdict. The number is the evidence; the judgement is the reader's.
  • Vague requests are not reproducible. Measured, not assumed: 5 of 9 repeated requests were identical on every constraint-driving field, and the ones that were not are the ones that underspecify the ride. The product cannot ask a clarifying question, so it guesses, and it does not say that it guessed.
  • One request type is still misread. An edit that states no length — “route me through Vilas Park and then the Arboretum” — is classified correctly three times in five. When it is not, the app generates a brand-new route and the rider's current one is gone, with nothing to undo back to. The durable fix is recording a new route's parent so undo can recover it; that is designed and not built, because the only evidence for it here is held out.
  • Single evaluator. The request set is written by the author. It is deliberately adversarial in places, but it is not a sample of real user traffic, and a real user base would produce phrasings this set does not contain.
  • The harness was wrong three times before it was right. Every correction is above and each is pinned by a test, but a first-run number from a new eval deserves less trust than this page's numbers now do, and that applies to anyone else's eval too.

Every case

The full results, including everything that failed

All 62 cases, with the measured outcome and the first hard check that failed. The featured demos above are four of these rows.

CaseRequestMeasuredVerdict
Everything that did not fully pass
Listed in full rather than summarized
CaseRequestVerdictFailed on
D18find me a flat stretch within 20 minutes of Olin Park Madison WI for 2x20 threshold intervalspartialspot.long_enough_for_a_rep
D21a short easy spin from Olin Park Madison WIfailoutcome.succeeded
H23route me through Vilas Park and then the Arboretumfailparse.request_type, parse.mode, parse.places_len_min
H24undofailoutcome.succeeded

Reproducibility

Rerunning this

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.

git clone https://github.com/NKimball1/route-gen-ai pip install -r requirements-dev.txt # offline: 100+ unit tests and the failure-injection cases, no keys, no server python -m pytest tests/ -q python -m evals.runner --run myrun --tier offline # live end-to-end: needs a self-hosted BRouter and an ANTHROPIC_API_KEY start_brouter.cmd python -m evals.runner --run myrun --tier live python -m evals.report --run myrun --compare baseline python -m evals.build_case_study --run myrun --baseline baseline

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.