When Zero Means Zero: An LLM Safety Net That Ate Real Queries

Ballpark Genius’s search bar takes plain English — “players with 30 or more home runs,” “who’s on pace for 40 steals” — and an LLM turns it into a structured filter against Postgres. LLMs being LLMs, they occasionally hallucinate a filter nobody asked for: a stray { stat: 'plateAppearances', value: 0, direction: 'above' } tacked onto an otherwise correct parse. A threshold of “0 or more” is meaningless — every player has 0 or more plate appearances — and worse, it can null out an entire result set through the JSON-path filter it compiles into. So the search service has a safety net: strip any parsed threshold where the value is exactly 0 and the direction is at_least/above/etc.

That net also ate every real query where a user typed “0” on purpose.

Ballpark Genius search results for "players with 0 or more three baggers," now correctly returning 18 sorted, ranked players

“Players with 0 or more three baggers” — a real, syntactically valid query that used to return nothing sensible.

The parse is identical either way

Type “players with 0 or more three baggers” into the search bar, and the LLM parses it to exactly the same shape as the hallucinated placeholder:

1
{ "stat": "triples", "direction": "at_least", "value": 0 }

There’s nothing in the parsed object that distinguishes “the model made this up” from “the user explicitly asked for this.” The stripper had no way to tell them apart, so it deleted both, every time. “1 or more” and anything higher worked fine, because value === 0 simply never matched — which is exactly why this sat unnoticed for a while. It’s the kind of bug that only shows up on the specific boundary condition nobody thinks to test, because a query for “0 of something” feels like a null query even when it isn’t one.

Losing the threshold broke more than the threshold

The consequence wasn’t just a missing filter — it silently broke sort order too. Downstream, sortBy gets inferred from whichever *_min/*_max filter key survived the parse. Strip the threshold, and there’s no filter key left to infer a sort from, so the query falls back to no sort at all. A request for the top 20 by some stat quietly stopped showing the top 20 — it showed whatever order the database happened to return, with no threshold and no ranking, and no error anywhere in the chain to say so.

The fix: check the query text before you strip

The stripper’s problem was that it only had the parsed shape to go on. The fix gives it a second source of truth: before deleting a zero-valued threshold, re-run that stat’s own threshold regex — the same THRESHOLD_PATTERNS used to parse the query in the first place, defined in stat-aliases.ts — against the raw text the user actually typed. If “0” for that stat has a real textual basis in the query, the threshold survives. If it doesn’t — if there’s no explicit zero anywhere in the sentence — it gets stripped as the hallucination it almost certainly is.

1
2
buildFallbackToolArgs("players with 0 or more three baggers", 'player')
// -> { stat: 'triples', direction: 'at_least', value: 0 } (now survives)

Same parsed shape, same value, but now the decision to keep or drop it is grounded in what the user actually wrote instead of a blanket rule about the number zero.

The broader shape of the bug

This is a pattern worth naming on its own: a safety net built to catch model noise, tuned against the failure case that motivated it, without checking whether it could also match a legitimate input that happens to look identical after parsing. The net wasn’t wrong to exist — LLM hallucination on placeholder values is real, and letting one through can null out a whole page of results. It just needed a second signal beyond the parsed value to tell the two cases apart, and that signal — the original query text — was sitting right there the whole time, already used earlier in the same pipeline.

— James