Verifying Topological Claims with DE-9IM

Check containment, adjacency and crossing exactly using the nine-intersection matrix, so a model's spatial assertions are confirmed, corrected or marked unverifiable.

Named predicates like “contains” and “touches” are convenient and occasionally not what a question means. The nine-intersection matrix underneath them describes exactly how two geometries relate, and reading it directly lets you check a claim against the relation the user actually intended rather than the closest available function name. This guide uses it to verify model assertions, as the exact-checking stage of spatial reasoning and relation inference.

When to Use This Approach

Use the named predicates for ordinary cases and drop to the matrix when a claim falls between them, when the boundary case matters, or when you need to explain to a reader why a claim failed.

Claim Named predicate When it is not enough
“inside” contains A shape touching the boundary from within
“next to” touches Data digitised separately never touches exactly
“crosses” crosses Lines that overlap for a stretch rather than crossing
“overlaps” overlaps Requires equal dimension; a point never overlaps a polygon
“covers” covers Differs from contains exactly at the boundary

The distinction between containing and covering is the one that surfaces most often in practice: a parcel whose edge lies exactly on a district boundary is covered by the district and not contained in it, and a system that reports “not inside” for that case will be told, correctly, that it is wrong.

Contains against covers at a shared boundaryA parcel wholly inside a district is both contained and covered; a parcel whose edge lies on the district boundary is covered but not contained, which is the case that produces disputed answers.contained and coveredcovered, not containedthe parcel edge lies on the boundary
One shared edge changes the answer. Both parcels are, in ordinary language, inside the district; only one of them satisfies the strict containment predicate, which is why the claim has to be mapped to a relation before it is checked.

Implementation

The verifier maps a natural-language claim to an intersection pattern, evaluates it, and returns a three-valued verdict with the matrix so a failure can be explained.

import logging
from dataclasses import dataclass
from typing import Optional

from shapely.geometry.base import BaseGeometry
from shapely.errors import GEOSException
from shapely.validation import make_valid

log = logging.getLogger("topology_verification")

# Patterns describing how the interiors, boundaries and exteriors of two geometries meet.
# "T" means they must intersect, "F" that they must not, "*" that it does not matter.
PATTERNS = {
    "inside":     "T*F**F***",     # strictly within, boundary not touching the outside
    "covered_by": "T*F**F***",     # same test, applied with covers semantics below
    "adjacent":   "F***T****",     # boundaries meet, interiors do not
    "crosses":    "T*T******",
    "overlaps":   "T*T***T**",
}


@dataclass(frozen=True)
class Verdict:
    holds: Optional[bool]          # None means unverifiable, never a guess
    matrix: Optional[str]
    note: str


def _prepare(geom: BaseGeometry) -> Optional[BaseGeometry]:
    if geom is None or geom.is_empty:
        return None
    if geom.is_valid:
        return geom
    try:
        fixed = make_valid(geom)
        return fixed if not fixed.is_empty else None
    except GEOSException as exc:
        log.warning("geometry could not be repaired: %s", exc)
        return None


def verify(subject: BaseGeometry, reference: BaseGeometry, claim: str,
           tolerance_m: float = 0.0, to_metric=None) -> Verdict:
    """Verify one relation claim exactly. Returns None when it cannot be checked."""
    a, b = _prepare(subject), _prepare(reference)
    if a is None or b is None:
        return Verdict(None, None, "one or both geometries are unavailable or unusable")

    if tolerance_m > 0 and to_metric is not None:
        try:
            a = to_metric(a).buffer(tolerance_m)      # tolerance for separately digitised data
            b = to_metric(b)
        except Exception as exc:
            return Verdict(None, None, f"could not apply tolerance: {exc}")

    pattern = PATTERNS.get(claim)
    if pattern is None:
        return Verdict(None, None, f"no intersection pattern defined for claim {claim!r}")

    try:
        matrix = a.relate(b)
        holds = a.relate_pattern(b, pattern)
    except GEOSException as exc:
        return Verdict(None, None, f"relation computation failed: {exc}")

    if claim == "covered_by":
        holds = b.covers(a)                            # boundary-inclusive containment
    return Verdict(holds, matrix, "" if holds else f"matrix {matrix} does not match {pattern}")

Returning the matrix alongside the verdict is what turns a failed check into an explanation. “Not inside” is a bare contradiction; “not inside — the parcel’s boundary meets the district’s boundary, so it is covered rather than contained” is an answer that resolves the disagreement in one sentence.

The tolerance parameter exists because exact adjacency is rare in real data. Two parcels digitised in different surveys will be a few centimetres apart or a few centimetres overlapping, and a strict boundary-touching test reports neighbours as unrelated. Applying a small buffer to one side, in a metric projection, restores the intended meaning — and the tolerance must be reported with the answer, because “adjacent within half a metre” is a materially different claim from “adjacent”.

Why exact adjacency fails on separately digitised dataTwo parcels captured in different surveys sit a few centimetres apart, so a strict touching test reports them as unrelated even though every reader would call them neighbours.a few centimetres apart on the groundstrict test: not adjacentwith a stated tolerance: adjacentand the tolerance appears in the answer
Capture accuracy, not topology. The gap is an artefact of two surveys, and a verification layer that ignores it will spend its time contradicting people about facts they can see on the ground.

Validation & Testing

from shapely.geometry import box, Point


def test_edge_touching_parcel_is_covered_not_contained():
    district = box(0, 0, 10, 10)
    parcel = box(0, 2, 4, 6)                       # shares the western edge
    assert verify(parcel, district, "inside").holds is False
    assert verify(parcel, district, "covered_by").holds is True


def test_unusable_geometry_returns_none_not_false():
    v = verify(None, box(0, 0, 1, 1), "inside")
    assert v.holds is None and "unavailable" in v.note


def test_unknown_claim_is_unverifiable_not_false():
    v = verify(box(0, 0, 1, 1), box(0, 0, 2, 2), "upstream_of")
    assert v.holds is None and "no intersection pattern" in v.note


def test_matrix_is_returned_for_a_failed_claim():
    v = verify(Point(20, 20), box(0, 0, 10, 10), "inside")
    assert v.holds is False and v.matrix and len(v.matrix) == 9

The second and third tests are the ones that keep the three-valued contract honest. Every simplification of this function will be tempted to return False for an input it could not evaluate, and that single change converts “we do not know” into “we checked and it is not so” across the entire system.

Gotchas & Edge Cases

Patterns applied to mismatched dimensions. Several relations are only defined between geometries of the same dimension: a point never overlaps a polygon, however much it may sit inside one. Check dimensions before applying a pattern, and report a dimension mismatch as unverifiable rather than false.

Tolerance applied in degrees. Buffering by a value that looks like half a metre but is expressed in degrees produces a buffer of tens of kilometres. Project before buffering, always, and assert the projection in the same test that asserts the tolerance.

Buffering both sides. Applying the tolerance to both geometries doubles it silently. Buffer one side only, and state which.

Relations that require matching dimensionsOverlapping is only defined between geometries of the same dimension, so a point-in-polygon question expressed as an overlap returns false for a point that is plainly inside.same dimensionmixed dimensionpolygon and polygonoverlap is meaningfulpoint and polygonoverlap is always falseuse containment instead
Always false is not the same as never true. The predicate is behaving exactly as specified; the claim was simply expressed with a relation that cannot hold for these two kinds of shape.

Invalid geometry silently repaired into a different answer. A self-intersecting polygon repaired by splitting into parts can genuinely change a containment result. Report that a repair occurred alongside the verdict, so a surprising answer can be traced to it.

A claim verified against the wrong reference. The commonest logical error is checking “is the site in the conservation area” against a different conservation area with a similar name. Verification is only as good as the grounding that supplied the reference, which is why place resolution carries identifiers rather than names.

Relations checked in a projected frame with a different origin. Both geometries must be in the same frame before any predicate runs. A mismatch produces a confident answer computed over geometry that is nowhere near each other, and no predicate detects it.

Frequently Asked Questions

Should the matrix be shown to the user?

Rarely to an end user and always to a developer. The nine-character string is meaningless to most readers and is the fastest possible explanation for anyone debugging a disputed claim. The useful pattern is to translate it into a sentence for the answer — "the boundaries meet but the interiors do not" — while logging the raw matrix for anyone who needs to reproduce the check.

What tolerance is defensible for adjacency?

The capture accuracy of the less accurate of the two datasets, which is usually documented and is almost never zero. Choosing it from the data rather than from convenience makes the resulting claim defensible: "adjacent within the stated accuracy of both sources" is a statement you can put in front of a surveyor. A tolerance chosen because it made a test pass is not.

How should relations between more than two geometries be handled?

As a conjunction of pairwise checks, each verified and reported separately. A claim that a site is inside one area and outside another is two claims, and collapsing them into a single verdict loses the information that one held and the other did not. The answer layer can then assert the part that verified and hedge the part that did not, which is almost always more useful than an all-or-nothing result.

Is it worth caching verification results?

Yes, keyed on the pair of geometry versions rather than on identifiers, because geometry changes and a cached topology result computed against a superseded boundary is exactly the kind of stale answer that erodes trust. With version-keyed caching the cache invalidates itself when either shape is corrected, which is the behaviour you want and is difficult to arrange any other way.

One last piece of advice on adoption. Introduce this verifier as a check on answers before introducing it as a source of them: run it silently for a period, log every claim it would have retracted, and read the list. That list is a direct measurement of how often the system currently asserts spatial relations it cannot support, and it is usually more persuasive than any argument for adding the layer.