A model asked for a position will always produce one, and the output is formatted identically whether it was recalled correctly, recalled wrongly, or invented. Screening is the cheap layer that separates those cases before anything expensive or consequential happens, and it is the first metric worth reporting in evaluation and benchmarking for spatial LLMs.
When to Use This Approach
Screen every coordinate a model produces, and screen before scoring rather than after. A hallucinated coordinate scores zero on any overlap metric and tells you nothing about the model’s geometric ability, so mixing the two failure classes makes both unreadable.
| Check | Cost | Catches |
|---|---|---|
| Coordinate domain | Free | Values outside the valid range for the frame |
| Null island | Free | Missing values rendered as zero |
| Expected region | Free | Positions in the wrong country or hemisphere |
| Repeated-digit pattern | Free | Fabricated numbers with implausible structure |
| Land or water mask | Cheap | Buildings in open ocean |
| Gazetteer agreement | A lookup | A real place, but not the one that was named |
The last row is the only expensive one and the only one that catches a plausible position attached to the wrong name — which is the failure that survives every other check and is the most damaging in practice.
Implementation
The screen returns a verdict with the first check that failed, so a report can be broken down by failure kind rather than by a single pass rate.
import logging
import re
from dataclasses import dataclass
from typing import Callable, Optional
log = logging.getLogger("coordinate_screen")
_REPEATED = re.compile(r"(\d)\1{4,}") # five or more identical digits in a row
@dataclass(frozen=True)
class Screen:
plausible: bool
failed_check: Optional[str]
detail: str
def screen_coordinate(
lon: float,
lat: float,
expected_bbox: tuple[float, float, float, float],
raw_text: str = "",
on_land: Optional[Callable[[float, float], bool]] = None,
) -> Screen:
"""Ordered plausibility checks, cheapest first. Never raises."""
try:
lon, lat = float(lon), float(lat)
except (TypeError, ValueError):
return Screen(False, "parse", "coordinate values are not numbers")
if not (-180.0 <= lon <= 180.0 and -90.0 <= lat <= 90.0):
return Screen(False, "domain", f"({lon}, {lat}) is outside the coordinate domain")
if abs(lon) < 1e-9 and abs(lat) < 1e-9:
return Screen(False, "null_island", "exactly zero — usually a missing value")
if raw_text and _REPEATED.search(raw_text):
return Screen(False, "digit_pattern",
"the coordinate text contains an implausible run of repeated digits")
w, s, e, n = expected_bbox
if not (w - 1 <= lon <= e + 1 and s - 1 <= lat <= n + 1):
return Screen(False, "region",
f"({lon:.4f}, {lat:.4f}) falls outside the expected region")
if on_land is not None:
try:
if not on_land(lon, lat):
return Screen(False, "water", "the position falls in open water")
except Exception as exc: # mask outage degrades the screen, not the turn
log.info("land mask unavailable (%s); skipping that check", exc)
return Screen(True, None, "")
The one-degree slack on the region test is deliberate. Expected extents are usually derived from a corpus or a gazetteer record and are frequently slightly tight, so a strict comparison rejects legitimate positions near the edge. A degree is generous at the scale this check operates on and still catches anything in the wrong country.
The gazetteer agreement check is separate because it needs a name as well as a position, and because it is the only one worth running asynchronously if latency matters.
def agrees_with_gazetteer(name: str, lon: float, lat: float, lookup,
tolerance_km: float = 25.0) -> Screen:
"""Does the model's position match where this name actually is?"""
try:
records = list(lookup(name))
except Exception as exc:
log.info("gazetteer unavailable for agreement check: %s", exc)
return Screen(True, None, "agreement not checked — gazetteer unavailable")
if not records:
return Screen(False, "unknown_name", f"no gazetteer record named {name!r}")
for rec in records:
w, s, e, n = rec.bbox
pad = tolerance_km / 111.0
if w - pad <= lon <= e + pad and s - pad <= lat <= n + pad:
return Screen(True, None, "")
return Screen(False, "name_position_mismatch",
f"{name!r} exists but not near ({lon:.4f}, {lat:.4f})")
Returning plausible when the gazetteer is unavailable, with a note saying so, is the right degradation. Failing closed would reject every coordinate during an outage, converting a screening gap into a total loss of function; the note is what stops that state from being invisible.
Validation & Testing
UK = (-8.0, 49.0, 2.0, 61.0)
def test_null_island_is_caught_before_the_region_check():
s = screen_coordinate(0.0, 0.0, UK)
assert s.failed_check == "null_island"
def test_out_of_domain_is_caught_first():
s = screen_coordinate(999.0, 0.0, UK)
assert s.failed_check == "domain"
def test_region_slack_admits_a_coastal_edge_case():
assert screen_coordinate(2.4, 51.1, UK).plausible
def test_mask_outage_does_not_reject():
def broken(_x, _y):
raise ConnectionError("mask down")
assert screen_coordinate(-3.19, 55.95, UK, on_land=broken).plausible
def test_name_position_mismatch_is_reported_distinctly():
s = agrees_with_gazetteer("Edinburgh", 2.35, 48.85, lookup_returning_edinburgh)
assert s.failed_check == "name_position_mismatch"
The first two tests pin the ordering, which matters because the reported failure kind drives the investigation. A null-island coordinate reported as a region failure sends someone looking at the model when the bug is in a parser.
Keep the fixtures small and pointed. Each of these tests exists to pin one behaviour, and a fixture that exercises three at once fails ambiguously.
Run the screen over historical model output when you first build it. The distribution of failure kinds across a few thousand past answers is the most informative hour you will spend on this, and it usually reveals that the largest class is a plumbing bug nobody knew about.
Gotchas & Edge Cases
Screening after conversion to geometry. By then a bad coordinate has already produced a valid-looking point, and the geometry engine has spent work on it. Screen the numbers, before parsing.
A region extent that is too tight. Derived extents frequently clip legitimate coastal and border positions. The slack in the region test handles it; removing that slack to “tighten the check” reliably produces complaints about correct answers being rejected.
The digit-pattern check firing on real coordinates. Some legitimate coordinates contain runs of repeated digits, and the check is a heuristic rather than a proof. Keep it as a screen that flags rather than one that rejects outright where the consequence of a false positive is high.
Treating an unknown name as a fabrication. A name absent from the gazetteer may be a gap in the gazetteer rather than an invention, especially for small or recently created features. Report it as its own class and let the volume tell you which it is.
Screening only model output. Coordinates also arrive from users and from documents, and both produce the same failure classes. The same screen applies, with a different expected extent.
Failure kinds collapsed in the metric. A single “hallucination rate” is easy to publish and impossible to act on. Report per kind, always — the aggregate can only tell you whether things got worse.
Frequently Asked Questions
Should a screened-out coordinate stop the answer?
It should stop that claim, not the answer. An agent that produced one bad coordinate among five useful statements should retract the one and keep the rest, with a note that a position could not be verified. Stopping the whole turn converts a partial failure into a total one, and users experience that as the system being brittle rather than careful.
How large should the gazetteer agreement tolerance be?
Large enough to accommodate a legitimate difference between a centroid and a boundary — twenty-five kilometres is a reasonable default for settlements and generous for buildings. Tightening it starts rejecting correct answers where the gazetteer's record is a point and the model gave a position within the same town. The check is looking for wrong-country errors, not for precision.
Is a land mask worth the dependency?
For corpora about land features, usually yes, because "in the ocean" is a common and unambiguous fabrication signature. Keep the mask coarse and local rather than precise and remote: a low-resolution mask that ships with the application catches the open-ocean cases and never fails, whereas a precise remote service adds latency and a dependency for a marginal gain in coastal accuracy you should not rely on anyway.
Does screening remove the need for grounding?
No — it is the fallback for cases where grounding was skipped or impossible. Grounding replaces a model-produced coordinate with a looked-up one and is strictly better; screening only tells you that a produced coordinate is not obviously wrong. Where both are available, ground first and screen the result as a cheap consistency check.
Where should the screen sit relative to the tool-calling loop?
Between the model’s output and any tool that consumes a position, so a screened-out coordinate never reaches a geometry engine or a map. Placing it after the tool call means the work is already done and, worse, that a downstream system may have acted on the position before the screen ran. The screen is cheap enough that running it at the boundary costs nothing measurable.