Automating Indoor Map Topology Checks in CI

This page covers the specific gate that stops a broken routing graph from ever being promoted — running topology validation as a required CI check on every candidate dataset — and it sits inside CI Gating for Map Updates, the stage between a rebuilt map and a deployed one.

What a Topology Check in CI Means

A topology check is an automated assertion, run before promotion, that the map’s routing graph is actually navigable — not just that its GeoJSON parses. Three failures matter more than any other for indoor wayfinding, and none of them is caught by schema validation alone:

  1. Invalid geometry. A self-intersecting room polygon or a zero-area sliver breaks point-in-polygon tests and downstream routing.
  2. A disconnected graph. If the routing graph splits into more than one connected component, some destinations are simply unreachable — the classic “no route found” on a fully mapped floor.
  3. Malformed door adjacency. A door is the edge between two spaces. A door that touches one space (a dead end) or three (an ambiguous junction) means the connectivity model is wrong at that opening.
The gate sequence a map change passes through before it can be published Four states. A map pull request opens and an artifact is built. The schema stage validates the GeoJSON envelope; an invalid envelope moves straight to blocked. A valid envelope advances to the topology stage, where the reachability checks run; any blocking failure there also moves to blocked, and the artifact is rejected rather than published. Only an artifact that clears both stages is eligible for promotion. Two gates, one rejection state, no path around either artifact built schema passed any blocking check fails invalid envelope Opened map PR raised Schema envelope validates Topology reachability holds Blocked artifact rejected

Schema first, topology second. Topology checks assume a well-formed envelope; running them on invalid input produces confusing failures that point at the wrong stage.

Running these as a CI gate — exit non-zero on any failure — means a candidate dataset that would strand users never reaches the artifact registry. This is validation before deploy, the mirror image of the telemetry-driven revert that happens after deploy in Rollback Triggers & Versioning.

Minimal Working Example

A self-contained check that fails the build on any of the three problems. It loads the candidate GeoJSON, asserts every geometry is valid, asserts the routing graph is one connected component, and asserts every door adjoins exactly two spaces. Any failure raises; the CLI wrapper turns that into a non-zero exit.

import logging
import sys

import geopandas as gpd
import networkx as nx
from shapely.geometry import shape

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)


def check_topology(path: str, expected_components: int = 1) -> None:
    """Assert geometry validity, graph connectivity, and door adjacency. Raise on any failure."""
    gdf = gpd.read_file(path)

    invalid = gdf[~gdf.geometry.is_valid]
    if not invalid.empty:
        raise AssertionError(f"{len(invalid)} invalid geometries: {list(invalid['feature_id'])[:5]}")

    spaces = gdf[gdf["space_class"].isin({"room", "corridor"})]
    doors = gdf[gdf["space_class"] == "door"]

    graph = nx.Graph()
    graph.add_nodes_from(spaces["feature_id"])
    for _, door in doors.iterrows():
        touching = spaces[spaces.geometry.touches(shape(door.geometry))]["feature_id"].tolist()
        if len(touching) != 2:
            raise AssertionError(f"door {door['feature_id']} adjoins {len(touching)} spaces, expected 2")
        graph.add_edge(touching[0], touching[1])

    components = nx.number_connected_components(graph)
    if components != expected_components:
        raise AssertionError(f"graph has {components} components, expected {expected_components}")

    logger.info("topology OK: %d spaces, %d doors, %d component(s)", len(spaces), len(doors), components)


if __name__ == "__main__":
    try:
        check_topology(sys.argv[1])
    except AssertionError as exc:
        logger.error("TOPOLOGY GATE FAILED: %s", exc)
        sys.exit(1)  # non-zero fails the CI job

Wire it into CI as a required job so a red check blocks the merge and the promotion:

name: map-topology-gate
on:
  pull_request:
    paths: ["datasets/**/*.geojson"]
jobs:
  topology:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install geopandas networkx shapely
      - name: Run topology gate
        run: python -m checks.topology datasets/${{ github.event.pull_request.number }}/floor.geojson

Check Reference

Check Method Pass condition Fail behaviour
Geometry validity shapely geometry.is_valid Every feature valid (no self-intersection, no zero area) Raise with the offending feature_ids
Graph connectivity networkx.number_connected_components Equals expected_components (usually 1 per floor) Raise with actual component count
Door adjacency geometry.touches count per door Exactly 2 spaces per door Raise with the door feature_id and its count
Exit behaviour sys.exit(1) on any AssertionError Zero exit only when all checks pass Non-zero exit fails the CI job and blocks merge
Artifact write a JSON report of failures Upload as a build artifact for triage
The six topology checks worth blocking a map deployment on A table of six continuous-integration checks. Requiring exactly one connected component catches a floor island, usually caused by a door gap that was never closed, and blocks the deploy. Requiring zero orphan nodes catches an unreachable point of interest placed outside any room. Requiring every level to have a vertical link catches a stranded storey where a lift was not modelled. A step-free reachability check catches a wing served only by stairs. An edge-length limit of sixty metres warns about a corridor that was never split into intermediate nodes. A duplicate feature id check catches ambiguous references caused by merging two levels. Every blocking check answers one question: is it reachable? Check Detects Severity Typical cause connected_components == 1 a floor island △ block a door gap never closed orphan nodes == 0 unreachable POI △ block POI placed outside any room every level has a vertical link stranded storey △ block lift not modelled step_free reachability inaccessible wing △ block only stairs to that wing edge length < 60 m missing intermediate node warn corridor not split duplicate feature_id ambiguous reference △ block two levels merged Blocking checks are all reachability; the warning is a quality signal, not a defect.

Five of six are reachability questions. Geometry can be valid, schema can pass, and the map can still be unusable — so the gate asks the only question a user asks: can I get from here to there?

The expected_components parameter is deliberate: some datasets legitimately have detached areas (a separate wing reachable only by an outdoor path, an unmapped mechanical floor). Set the expected count explicitly per dataset rather than hard-coding 1, so an intended detachment does not read as a regression.

Common Errors & Fixes

The check warns but the build stays green. The most common and most dangerous failure: the check logs a problem but the job still exits 0, so broken datasets promote anyway. A gate must fail, not warn. Ensure every failed assertion propagates to a non-zero exit:

Where map defects were caught before and after the gate was introduced Two curves over eight weeks. Before the topology gate existed, in week one, no defects were caught in continuous integration and fourteen were reported by users. As the checks were added week by week, CI catches rise to twenty-seven per week and plateau, while user-reported defects fall to three by week four and to zero by week seven. The total defect count is roughly unchanged; what changed is who finds them. Same defects, found four days earlier 0 10 20 30 1 2 3 4 5 6 7 8 week after the topology gate was introduced defects (cumulative per week) defects found in CI defects reported by users

The gate did not reduce defects — it moved them. The same broken floors were always being produced; the difference is that a blocked pipeline costs a re-export and a user report costs a support ticket and a cache purge.

except AssertionError as exc:
    logger.error("TOPOLOGY GATE FAILED: %s", exc)
    sys.exit(1)  # NOT just logger.warning(...) and a fall-through to exit 0

Non-deterministic component counts between runs. The same dataset passes on one run and fails on the next because feature iteration order varies, and floating-point touches tests flip on borderline-adjacent geometry. Snap coordinates to a fixed grid before the adjacency test and sort features by feature_id so the graph is built identically every time:

gdf["geometry"] = gdf.geometry.set_precision(1e-6)  # deterministic adjacency
gdf = gdf.sort_values("feature_id").reset_index(drop=True)

KeyError: 'feature_id' / missing fixtures in CI. The check runs against a path that does not exist, or the dataset lacks the envelope fields the check reads (feature_id, space_class). This is usually a fixture-wiring problem, not a data problem. Fail fast with a clear message and assert the required columns are present before running the topology logic, so a missing file is reported as a setup error rather than a topology error.

Integration Point

This gate is the last check before a dataset becomes a promotable artifact. It runs inside CI Gating for Map Updates, immediately after the routable GeoJSON is produced — for DWG-sourced maps, that is the output of the automated floor plan parsing and vectorization pipeline. A green gate is what authorizes promotion; a red gate blocks it. Downstream, the same versioned artifact this gate approves is the last-known-good target that Rollback Triggers & Versioning reverts to when live telemetry says a later release degraded navigation — so the gate here and the revert there defend the same promise from opposite ends of the deploy.

Frequently Asked Questions

Should a topology failure block the merge or just warn?

Block it. A warning that promotes anyway defeats the entire point of the gate — a disconnected graph or a dead-end door strands real users, and “we logged it” is not a mitigation. Make the check exit non-zero on any failure and mark the CI job as required so the merge cannot land while it is red. Reserve warnings for genuinely advisory signals (a slightly high feature count, a slow build) that do not affect navigability.

Why check connectivity with networkx instead of trusting the routing engine?

Because the routing engine only fails when someone requests an unreachable destination, which may be in production, on a user’s phone. networkx.number_connected_components on the door-adjacency graph proves reachability for the whole floor at build time, before promotion, in milliseconds. It answers a global question (“is every space reachable from every other?”) that a per-query router never asks. The check is cheap enough to run on every candidate dataset and catches the fault where it is free to fix.

How do I handle a floor that is legitimately in two pieces?

Set expected_components to the true number for that dataset rather than forcing 1. Some floors genuinely split — a wing reachable only across an outdoor courtyard, or a secure area with no in-model connection. Encoding the expected component count per dataset keeps an intentional detachment from reading as a regression, while still failing the build if the count changes unexpectedly. Store that expected value alongside the dataset so it is reviewed like any other config.

This page is part of the CI Gating for Map Updates guide within the Production-Ready Indoor Map Deployment reference.