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:
- Invalid geometry. A self-intersecting room polygon or a zero-area sliver breaks point-in-polygon tests and downstream routing.
- 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.
- 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.
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/$/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 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:
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.
Related
- CI Gating for Map Updates — the promotion gate this topology check runs inside.
- Rollback Triggers & Versioning — the post-deploy revert that defends the same promise this gate defends pre-deploy.
- Automated Floor Plan Parsing & Vectorization — the pipeline that produces the routable GeoJSON this gate validates.
This page is part of the CI Gating for Map Updates guide within the Production-Ready Indoor Map Deployment reference.