A spatial agent has too many moving parts for spot checks to be meaningful: a chunking change, a library upgrade and a prompt edit all move the same numbers, and none of them announces itself. A regression harness is the apparatus that makes those movements attributable — a versioned case set, a deterministic runner, and a self-check that fails before the agent is blamed. This guide builds it, as the machinery behind evaluation and benchmarking for spatial LLMs.
When to Use This Approach
Build the harness before the second person joins the project, not after the first mysterious regression. Its value comes from history, and history only accumulates if the harness exists early.
| Change being made | What the harness must isolate |
|---|---|
| Prompt or instruction edit | Agent behaviour, everything else pinned |
| Model or version change | Agent behaviour, tokenizer recorded |
| Geometry library upgrade | Harness drift — the self-check fires first |
| Chunking or retrieval change | Retrieval quality, scored separately |
| Truth-data correction | Nothing — historical comparisons are invalidated |
The last row is the one teams get wrong. Correcting a truth geometry improves the case set and makes every previous score incomparable, so it must bump the case-set version rather than being applied silently.
Implementation
The harness pins everything it can, runs the case set, and records the environment alongside the results so a future comparison knows what it is comparing.
import hashlib
import json
import logging
import platform
from dataclasses import dataclass, asdict
from typing import Callable, Sequence
log = logging.getLogger("regression_harness")
@dataclass(frozen=True)
class Environment:
case_set_version: str
agent_version: str
tokenizer: str
geos_version: str
proj_version: str
python: str
@dataclass(frozen=True)
class Run:
environment: Environment
results: tuple[dict, ...]
case_set_digest: str
def case_set_digest(cases: Sequence[dict]) -> str:
"""A stable digest of the case set, so a silent edit is detectable."""
payload = json.dumps(
[{k: c[k] for k in sorted(c) if k != "notes"} for c in cases],
sort_keys=True, separators=(",", ":"), default=str)
return hashlib.sha256(payload.encode()).hexdigest()[:16]
def capture_environment(case_set_version: str, agent_version: str,
tokenizer: str) -> Environment:
"""Record what could move a score, before anything runs."""
try:
from shapely import geos_version_string
geos = geos_version_string
except Exception: # a missing library is itself a finding
geos = "unavailable"
log.warning("geometry library version could not be determined")
try:
import pyproj
proj = pyproj.__proj_version__
except Exception:
proj = "unavailable"
return Environment(case_set_version, agent_version, tokenizer,
geos, proj, platform.python_version())
Recording the geometry and projection library versions is not bureaucracy. A geometry engine upgrade changes repair behaviour on invalid input, which moves overlap scores by a percent or two — indistinguishable from a model regression unless the version is in the record.
The runner itself must be deterministic and must fail fast when its own behaviour has changed.
FROZEN = {"case-001": 0.8734, "case-014": 0.0000, "case-102": 1.0000}
class HarnessDrift(AssertionError):
"""The harness scores differently from before — investigate it, not the agent."""
def self_check(score_case: Callable[[str], float], tolerance: float = 1e-4) -> None:
"""Score three frozen cases before the sweep. Fails loudly if the harness moved."""
for case_id, expected in FROZEN.items():
try:
got = score_case(case_id)
except Exception as exc:
raise HarnessDrift(f"self-check case {case_id} raised: {exc}") from exc
if abs(got - expected) > tolerance:
raise HarnessDrift(
f"harness drift on {case_id}: expected {expected}, got {got:.4f}. "
"Check library versions and truth data before blaming the agent.")
def run(cases: Sequence[dict], answer: Callable[[dict], dict],
score: Callable[[dict, dict], dict], env: Environment) -> Run:
"""Run the sweep. Individual case failures are results, never aborts."""
self_check(lambda cid: score(_case(cases, cid), answer(_case(cases, cid)))["score"])
results = []
for case in sorted(cases, key=lambda c: c["case_id"]): # deterministic order
try:
produced = answer(case)
except Exception as exc: # a crash is a result
log.warning("case %s raised: %s", case["case_id"], exc)
results.append({"case_id": case["case_id"], "family": case["family"],
"score": 0.0, "parsed": False, "note": f"agent raised: {exc}"})
continue
results.append({**score(case, produced), "case_id": case["case_id"],
"family": case["family"], "region": case.get("region", "all")})
return Run(env, tuple(results), case_set_digest(cases))
Two properties make this harness trustworthy. Cases run in sorted order, so a change in dictionary iteration cannot reorder a sweep and produce a different sample under a partial run. And an agent crash becomes a scored result rather than an aborted sweep, because a crash on one case is exactly the kind of regression the harness exists to catch — and a sweep that stops at the first one measures nothing.
Validation & Testing
def test_self_check_fires_before_the_sweep():
def drifted(_case_id):
return 0.5 # nothing like the frozen expectations
try:
self_check(drifted)
except HarnessDrift as exc:
assert "before blaming the agent" in str(exc)
return
raise AssertionError("harness drift must stop the sweep")
def test_agent_crash_becomes_a_scored_result():
def crashing(_case):
raise RuntimeError("tool timeout")
out = run(CASES, crashing, score_fn, ENV)
assert len(out.results) == len(CASES)
assert all(r["score"] == 0.0 and not r["parsed"] for r in out.results)
def test_case_set_digest_changes_when_truth_changes():
before = case_set_digest(CASES)
edited = [{**CASES[0], "truth_geometry": "POLYGON((0 0,1 0,1 1,0 0))"}, *CASES[1:]]
assert case_set_digest(edited) != before
def test_ordering_is_deterministic():
a = run(CASES, answer_fn, score_fn, ENV)
b = run(list(reversed(CASES)), answer_fn, score_fn, ENV)
assert [r["case_id"] for r in a.results] == [r["case_id"] for r in b.results]
The third test is the one that keeps truth-data changes honest. A digest that moves when a truth geometry is edited turns a silent invalidation of history into a visible one, and the comparison tooling can then refuse to plot two runs with different digests on the same axis.
Gotchas & Edge Cases
A harness that stops at the first failure. Standard test-runner behaviour and exactly wrong here: the sweep is a measurement, not a build, and a partial measurement is worse than none because it looks complete.
Non-determinism from the agent. A sampled model produces different answers on identical input, so a single sweep conflates real change with sampling noise. Lower the temperature for evaluation runs, or run a subset repeatedly and report the spread — but say which you did.
Environment captured after the run. Library versions read at the end of a long sweep can differ from those at the start in a container that was updated mid-run. Capture first, and store the record with the results rather than alongside them.
Cases whose truth was derived from the system. A case labelled from the agent’s own past output tests that the agent still agrees with itself. Label from the source data, ideally by someone who did not build the agent.
Digest computed over unordered data. A digest that changes when a dictionary is re-serialised is worse than none, because it invalidates history for no reason. Sort keys, exclude free-text notes, and test that a cosmetic edit does not move it.
A frozen self-check that is silently updated. When the self-check fails and somebody updates the expected values to match, the harness has lost its only defence against its own drift. Changing a frozen value should require the same evidence as changing a threshold.
Frequently Asked Questions
How often should the sweep run?
On every change to the agent, and on a schedule regardless — nightly is common and daily is enough. The scheduled run is what catches drift that no change caused: a library updated by a base-image refresh, a data source that changed shape, an external service whose behaviour moved. Those are invisible to a change-triggered sweep because nothing in your repository changed.
Should the harness call real external services?
For the agent under test, yes — a harness that mocks the geometry engine is testing the mock. For anything slow or rate-limited, record and replay: capture real responses once, replay them thereafter, and refresh the recordings on a schedule. That keeps sweeps fast and deterministic while still exercising the real integration on a known cadence.
What belongs in a case beyond the question and the truth?
The family, the region, and a note explaining why the case exists. The first two drive the report breakdown; the third is what stops a future maintainer from deleting a case that looks redundant. A case labelled "added after the Newport incident, checks that ambiguous toponyms refuse rather than guess" survives a spring clean that a bare question and geometry will not.
How should the harness handle cases that are expected to fail?
Mark them explicitly rather than removing them, and assert that they still fail. A known limitation that starts passing is information — either it was fixed or the case stopped testing what it used to — and a harness that only tracks successes cannot tell you either way.
One organisational note. The case set belongs in the same repository as the agent, versioned alongside it, so a change to behaviour and the case that covers it land in the same commit. A case set in a separate store drifts out of step within weeks, and the first sign is a sweep that fails against a version of the agent that no longer exists.