Creating minimal parcel fixtures that reproduce topology bugs

GEOSException: TopologyException: found non-noded intersection between LINESTRING (571043.21 289114.87, 571051.44 289120.13) and LINESTRING (571051.44 289120.13, 571043.21 289114.87) at 571047.3 289117.5. The overlay ran for eleven minutes before raising, the input is a 41 000-parcel county layer, and the coordinates in the message belong to a linestring you cannot find. Debugging that directly is hopeless; the productive move is to shrink the input until the failure is small enough to look at. This guide does that mechanically — bisection on the feature set, then on the ring — producing a fixture of a handful of vertices that still fails, which then becomes a permanent regression test. It is the fixture-building practice for testing spatial data pipelines.

Diagnosis: making the failure deterministic before shrinking it jump to heading

Shrinking only works on a reproducible failure, and spatial failures are often nearly deterministic in ways that waste an afternoon.

Pin the library versions. GEOS changes its noding behaviour between releases, and a fixture that fails on GEOS 3.11 may pass on 3.12. Record the versions in the fixture directory or the shrink is not reproducible either.

Features and vertices surviving each bisection round Line chart over eight bisection rounds showing how many features and how many total vertices still reproduce a non-noded intersection failure. Features fall from 41 216 to 20 608, then 5 152, 644, 82, 11, 3 and finally 2. Total vertices fall from 812 000 to 38 by the last round. The failure reproduces at every step, which is the invariant the bisection maintains. Features and vertices surviving each bisection round 0 200000 400000 600000 800000 1000000 0 1 2 3 4 5 6 7 Bisection round Features / vertices (log-like scale by value) features still failing total vertices
Eight rounds take the input from 41 216 features to 2, and the failure reproduces at every step — that invariant is what makes the result trustworthy. The last two rounds are where the bug becomes readable: two parcels and 38 vertices fit on a screen.

Remove ordering dependence. Many overlay failures depend on the order features are processed, so a run that reads a GeoPackage in file order and a run that reads it after a spatial sort fail differently. Sort features by a stable key before shrinking, and keep that sort in the fixture.

Confirm the failure is in the geometry, not the volume. A MemoryError at 41 000 parcels is a different problem and will not shrink. Run the same operation on a random 5% sample: if it succeeds, you have a scale problem; if it fails, you have a geometry problem and shrinking will find it.

def is_reproducible(features, op, attempts=3) -> bool:
    """The same input must fail the same way every time before shrinking begins."""
    errors = []
    for _ in range(attempts):
        try:
            op(features)
            return False               # it passed: not reproducible
        except Exception as exc:
            errors.append(f"{type(exc).__name__}: {exc}")
    return len(set(errors)) == 1       # identical failure each time

Step-by-step implementation jump to heading

1. Bisect the feature set jump to heading

def shrink_features(features, op):
    """Delta-debugging on the feature list: repeatedly try to drop half, keeping any
    subset that still fails. Converges on a minimal failing set, which for overlay
    bugs is usually two or three parcels."""
    current = list(features)
    granularity = 2

    while len(current) >= 2:
        chunk = max(1, len(current) // granularity)
        reduced = False
        for start in range(0, len(current), chunk):
            candidate = current[:start] + current[start + chunk:]
            if not candidate:
                continue
            if still_fails(candidate, op):
                current = candidate
                granularity = max(granularity - 1, 2)
                reduced = True
                break
        if not reduced:
            if granularity >= len(current):
                break
            granularity = min(granularity * 2, len(current))

    return current


def still_fails(features, op) -> bool:
    try:
        op(features)
        return False
    except Exception:
        return True

For a non-noded intersection this normally lands on two parcels — the pair whose shared edge is not noded — and that alone usually explains the bug.

Three preconditions, and what happens if you skip each A three-row by three-column matrix of shrinking preconditions. Rows are pinning the library versions, removing ordering dependence, and confirming the failure is in the geometry rather than the volume. Columns are why it matters, what happens if it is skipped, and how to check it. Skipping the version pin makes the shrink unreproducible elsewhere. Skipping the sort makes the bisection chase a moving target. Skipping the volume check means shrinking a memory error, which never converges. Three preconditions, and what happens if you skip each why it matters if you skip it how to check Pin the library versions GEOS noding changes by release shrink is unreproducible record versions Remove ordering dependence overlay order changes failures bisection chases a moving target sort by a stable key Confirm it is geometry, not volume a MemoryError will not shrink bisection never converges run a 5% sample do this watch out the cost
All three failures waste the same afternoon in different ways. The volume check is the cheapest and most often skipped: if a random 5% sample succeeds, you have a scale problem and no amount of bisection will find a geometry bug that is not there.

2. Bisect the vertices within the surviving features jump to heading

Two parcels with 400 vertices each is still too much to read. The same technique applies to the rings, with one extra condition: the shrunken ring must remain a valid closed polygon, or you are debugging a different bug.

from shapely.geometry import Polygon


def shrink_ring(polygon, op_on_single, min_vertices=4):
    """Drop vertices while the failure survives and the ring stays a closed polygon."""
    coords = list(polygon.exterior.coords)[:-1]          # drop the closing duplicate
    i = 0
    while len(coords) > min_vertices and i < len(coords):
        trial = coords[:i] + coords[i + 1:]
        candidate = Polygon(trial + [trial[0]])
        # A shrink that changes WHY it fails is not a shrink. Requiring the same
        # exception type keeps the bisection honest.
        if same_failure(op_on_single, candidate, polygon):
            coords = trial
        else:
            i += 1
    return Polygon(coords + [coords[0]])


def same_failure(op, candidate, original) -> bool:
    def kind(geom):
        try:
            op(geom)
            return None
        except Exception as exc:
            return type(exc).__name__
    return kind(candidate) is not None and kind(candidate) == kind(original)

3. Round the coordinates so a human can read them jump to heading

A minimal fixture with coordinates like 571043.2100001 is minimal and still unreadable. Translate to a round origin and snap to a grid, checking at each step that the failure survives.

from shapely import affinity


def humanise(geoms, op, grid=0.01):
    """Translate to the origin and snap, keeping the failure. Purely cosmetic, and
    it is what turns a fixture into something reviewable."""
    minx = min(g.bounds[0] for g in geoms)
    miny = min(g.bounds[1] for g in geoms)
    moved = [affinity.translate(g, -minx, -miny) for g in geoms]
    if not still_fails(moved, op):
        return geoms                                  # translation changed it: keep original

    snapped = [Polygon([(round(x / grid) * grid, round(y / grid) * grid)
                        for x, y in g.exterior.coords]) for g in moved]
    return snapped if still_fails(snapped, op) else moved

Translation can change a floating-point failure, which is itself informative: a bug that disappears when the coordinates move is a precision bug rather than a topology bug, and the fix is a precision model rather than a geometry repair.

4. Emit the fixture as code, with the finding written down jump to heading

def emit_fixture(geoms, path, error, versions):
    lines = [
        '"""Minimal reproduction, shrunk from a 41 000-parcel county layer.',
        "",
        f"Failure: {error}",
        f"GEOS {versions['geos']}, shapely {versions['shapely']}.",
        "",
        "Shrunk from 41 216 features / 812 vertices to "
        f"{len(geoms)} features / {sum(len(g.exterior.coords) - 1 for g in geoms)} vertices.",
        '"""',
        "from shapely.geometry import Polygon", "",
        "FEATURES = [",
    ]
    for g in geoms:
        pts = ", ".join(f"({x:g}, {y:g})" for x, y in list(g.exterior.coords)[:-1])
        lines.append(f"    Polygon([{pts}]),")
    lines.append("]")
    path.write_text("\n".join(lines) + "\n")

Verification & testing jump to heading

def test_the_shrunken_fixture_still_fails():
    """The fixture's whole purpose. If this ever passes, the bug is fixed and the
    test below should be the one asserting the fix."""
    with pytest.raises(GEOSException, match="non-noded"):
        overlay(FEATURES)


def test_make_valid_repairs_it():
    """The fix, asserted against the same minimal input."""
    repaired = [make_valid(g) for g in FEATURES]
    result = overlay(repaired)
    assert result.is_valid
    assert abs(result.area - sum(g.area for g in FEATURES)) < 0.01


def test_shrinking_preserves_the_failure_kind():
    big = load_fixture("county_extract_subset.gpkg")
    small = shrink_features(big, overlay)
    assert len(small) < len(big)
    assert still_fails(small, overlay)
    with pytest.raises(GEOSException, match="non-noded"):
        overlay(small)


def test_shrinker_terminates_on_a_passing_input():
    ok = [box(0, 0, 10, 10), box(20, 20, 30, 30)]
    assert not is_reproducible(ok, overlay)

Keeping both of the first two tests is the point: one asserts the bug reproduces on the minimal input, the other asserts the repair works on it. Together they document the bug and the fix in a form the next person can run in a second.

Failure recovery jump to heading

The failure disappears during shrinking. Usually order dependence or a precision effect. Re-run with the feature sort pinned; if it still vanishes only after translation, you have found a precision bug and the fixture should record the original coordinates with a note, because the absolute position is part of the reproduction.

What the shrunken result tells you A four-row by three-column matrix of shrinking outcomes. Rows are a result of two features, a result of one feature, a failure that vanishes after translation, and a bisection that runs for hours. Columns are what the outcome means, the class of bug it points at, and the fix. Two features means an interaction bug in the overlay. One feature means self-intersecting geometry, which is a cheaper validate-on-ingest fix. A failure that vanishes on translation is a floating-point precision bug rather than a topology one. What the shrunken result tells you what it means class of bug fix Shrinks to 2 features an interaction overlay noding make_valid + a fixture Shrinks to 1 feature self-intersecting input invalid geometry validate on ingest Vanishes after translation position-dependent floating-point precision set a precision model Runs for hours quadratic bisection not a bug at all cap iterations, accept 5 features cheap needs thought deeper process
The shrunken size is itself a diagnosis. Two features is an interaction bug; one feature is a validation gap you can close on ingest and never see again; and a failure that disappears when the coordinates move is about precision, not topology, so a geometry repair will not fix it.

Shrinking runs for hours. The bisection is quadratic in the worst case, and the overlay is the expensive part. Cap it: run the feature-set bisection to a few hundred iterations, take whatever it has, and shrink rings only within the surviving features. A fixture of five parcels is already a hundredfold improvement on 41 000.

The minimal fixture is one parcel. Then it is not an interaction bug and the geometry is self-intersecting on its own. That is a cheaper bug: validate on ingest and the layer never contains it, which is a schema-validation fix rather than an overlay fix.

Frequently asked questions jump to heading

Why not just fix the geometry and move on?

Because the same county will publish the same shape next month. Without a fixture the bug returns and costs another eleven-minute run plus an afternoon of bisection. The fixture converts that recurring cost into a one-second test, and it also documents which pathology the pipeline is contracted to survive.

Should the shrunken fixture keep its original coordinates?

Translate to a round origin if the failure survives, because readability is what makes the fixture reviewable in a year. If translating makes the failure vanish, keep the original coordinates and note why: the absolute position is then part of the reproduction, which tells you the bug is about floating-point precision rather than topology.

How small is small enough?

Small enough to read the coordinates and see the problem — typically two or three features and under twenty vertices. Stop shrinking when the shape stops being informative rather than when the algorithm stops making progress; a four-vertex bowtie that a person can immediately recognise is a better fixture than a mechanically minimal three-vertex degenerate case.

Does this work for failures that are not exceptions?

Yes, with a different predicate. Replace “raises” with the property you care about — area not preserved, row count wrong, a hole lost — and the same bisection finds a minimal input exhibiting it. Silent wrong answers are actually the better use of the technique, because they are the ones nobody can otherwise localise.