Building Regression Test Harnesses for Spatial Agents

Build a harness that runs a versioned case set deterministically, distinguishes its own drift from the agent's, and turns every production failure into a permanent test.

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.

Four things that move the same numberA score change can come from the agent, the harness, the libraries or the truth data, and without pinning and self-checks all four look identical in a report.the agent changeda library changedthe harness changedthe truth data changedwhat you wanted to measureeverything else must be pinnedrepair behaviour moves scorespin versions, record thema scoring edit, a new fixturethe self-check fires firsta corrected geometrybump the case-set version
Only the top-left is a finding. The other three are the reasons a team spends a week investigating a model that never changed, and each one is prevented by a version stamp rather than by cleverness.

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.

How a case set should growCases are added from production failures and never retired, so the set accumulates every regression the system has ever had and each one is checked on every sweep.seed set+ failures+ more failures+ every one since50 cases80140300 and risingNothing is retired — a case that has passed for a year is the one that catches the next rewrite
Growth is the point, not a problem to manage. Each case costs a second to run and encodes one failure somebody once had to diagnose, which makes retiring old cases the most expensive kind of tidying available.

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.

A sweep that aborts against one that completesStopping at the first failing case reports a fraction of the set and looks like a completed run; scoring every case produces a measurement in which the failures are visible as results.abortsthe remaining 280 cases never rancompletes280 more cases, scoredA sweep is a measurement, not a build — a partial one looks complete and is not
Test-runner instincts are wrong here. Failing fast is right when the goal is a green build and wrong when the goal is a distribution, because the cases after the first failure are exactly the ones that tell you how widespread it is.

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.