CI Gating for Map Updates
A continuous-integration gate is the point in the Production-Ready Indoor Map Deployment lifecycle where a floor-plan edit stops being someone’s opinion and becomes a deployable artifact that automation has proven correct. Indoor mapping datasets are densely interdependent: a single misaligned polygon, a broken adjacency edge, or a malformed routing weight cascades into failed wayfinding sessions, wrong space-utilization metrics, and degraded SDK performance. The gate is a stateless validation layer that consumes a map artifact — the canonical GeoJSON FeatureCollection produced upstream — runs deterministic integrity checks, and emits a single pass/fail signal to the promotion workflow. It never mutates source geometry; it only decides whether that geometry is allowed to advance.
The Problem: Defects That Only Surface on a Visitor’s Phone
The failure mode this page exists to prevent is the silent regression — a map that loads, renders, and looks correct in review but routes a wheelchair user into a sealed corridor at 9 a.m. on a Monday. Indoor wayfinding has almost no tolerance for stale or malformed topology, because indoors there is no GPS to correct a bad route and no human driver to override it. When a defect slips past review, the symptoms are specific and reportable:
- Routing dead ends. A door feature was exported without its matching graph edge, so the routing graph believes a room has no exit and the engine returns “no path found” on a floor that is fully walkable.
- Cross-storey wall-walking. Two levels were merged with inconsistent
floor_levelvalues, so the graph connects the third and fourth floors through solid structure — a class of bug owned by Level Mapping & Z-Axis Logic but caught here. - Coordinate drift. A CAD re-export shifted the local origin, every feature is offset by a metre, and POI markers float off the geometry.
- Latency cliffs. A dense cluster of redundant corridor nodes balloons shortest-path cost and pushes routing responses past their SLA, degrading every client that consumes the floor.
Each of these is cheap to catch at the gate and expensive to catch in production. The gate’s job is to make the boundary where a defect is introduced the same boundary where it is rejected. It does that with three sequential stages — schema and topology, routing-graph integrity, then API contract and performance — each running in an isolated runner, each producing structured telemetry, and each halting the pipeline on a non-zero exit code.
Prerequisites & Dependencies
Before wiring the stages below into CI, you need the following in place:
- The canonical GeoJSON envelope. Every artifact is a
FeatureCollectionwith a top-levelmetadatablock carryingbuilding_id,floor_level,map_version,coordinate_system, and atopology_hash. The stages key all of their reporting on those fields, so the producing pipeline must already emit the envelope established in JSON Schema Design for Indoor Maps. - A versioned schema document. A JSON Schema (Draft 2020-12) file, tracked in the same repository as the validator, with
additionalProperties: falseso structural drift fails loudly rather than passing silently. - A consistent coordinate frame. Topology checks assume every floor was already projected into a single Indoor Coordinate Reference System; the gate validates relationships, not reprojection.
- Libraries.
jsonschema(4.x) for structural validation,shapely(2.x) for computational geometry,networkx(3.x) for graph connectivity, andrequestspluspydantic(2.x) for the API-contract stage. All are pure-Python or wheel-distributed, so the runner needs no system GIS stack. - A dedicated validation endpoint. Stage 3 issues routing requests; point it at an internal staging endpoint, not production, so a validation burst never trips production-side rate limiting.
How It Works: Three Sequential Gates
The pipeline is a fail-fast chain. Stage 1 proves the artifact is structurally and geometrically sound; Stage 2 proves the routing graph derived from it is navigable; Stage 3 proves the live routing engine answers correctly and fast enough when that artifact is loaded. A failure at any stage short-circuits the rest, because there is no value in load-testing an API against a graph that is already known to be disconnected. Violations are classified as FATAL (blocks promotion) or WARNING (logged for review), and every message carries the exact feature, node, or edge identifier so a GIS developer can jump straight to the offending geometry. Only when all three stages pass does the orchestration step compute the topology_hash and hand the artifact to the promotion workflow, where downstream stages like Cache Invalidation Strategies and Rollback Triggers & Versioning take over.
Step-by-Step Implementation
The three stages run as independent scripts so a failure isolates cleanly to one runner. Each reads the same artifact path, returns a POSIX exit code, and logs structured violations.
Step 1: Schema enforcement and spatial topology
Validation begins with strict adherence to the published schema, which catches structural drift early — missing floor_level references, invalid coordinate precision, unclosed polygon rings, or a mismatched coordinate_system declaration. Topological validation then extends beyond JSON structure to verify spatial relationships with Shapely: zero self-intersections in room boundaries, consistent floor_level alignment across overlapping features, and valid accessibility flags on every room. Failures are split into FATAL and WARNING buckets, and each carries the feature id and a coordinate reference.
#!/usr/bin/env python3
"""Stage 1: schema and spatial-topology validator for indoor map artifacts."""
import json
import logging
import sys
from pathlib import Path
from typing import Any
import jsonschema
from shapely.geometry import MultiPolygon, Polygon, shape
from shapely.validation import explain_validity
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger(__name__)
SCHEMA_PATH = Path("schemas/indoor_map_v2.schema.json")
MAP_PATH = Path("artifacts/bldg04_floor3.geojson")
def validate_schema(data: dict[str, Any]) -> list[str]:
"""Validate the FeatureCollection against the indoor-map JSON schema."""
try:
with open(SCHEMA_PATH, "r", encoding="utf-8") as fh:
schema = json.load(fh)
except (FileNotFoundError, json.JSONDecodeError) as exc:
logger.error("schema document unreadable: %s", exc)
raise
validator = jsonschema.Draft202012Validator(schema)
errors: list[str] = []
for error in validator.iter_errors(data):
path = ".".join(str(p) for p in error.absolute_path)
errors.append(f"SCHEMA_FAIL at '{path}': {error.message}")
return errors
def validate_topology(collection: dict[str, Any]) -> tuple[list[str], list[str]]:
"""Run spatial-topology checks on every feature in the collection."""
fatals: list[str] = []
warnings: list[str] = []
for feat in collection.get("features", []):
geom = shape(feat["geometry"])
props = feat.get("properties", {})
fid = feat.get("id", "unknown")
if not geom.is_valid:
fatals.append(f"TOPO_FATAL [{fid}]: {explain_validity(geom)}")
if isinstance(geom, Polygon):
rings = [geom.exterior.coords]
elif isinstance(geom, MultiPolygon):
rings = [p.exterior.coords for p in geom.geoms]
else:
rings = []
precision_eps = 1e-7 # below 6 decimals is within rounding noise
if any(
abs(x - round(x, 6)) > precision_eps or abs(y - round(y, 6)) > precision_eps
for ring in rings
for x, y, *_ in ring
):
warnings.append(f"TOPO_WARN [{fid}]: coordinate precision exceeds 6 decimals")
if props.get("category") == "room" and "accessibility_rating" not in props:
fatals.append(f"TOPO_FATAL [{fid}]: room feature missing accessibility_rating")
return fatals, warnings
def main() -> int:
logger.info("loading map artifact: %s", MAP_PATH)
try:
with open(MAP_PATH, "r", encoding="utf-8") as fh:
data = json.load(fh)
except (FileNotFoundError, json.JSONDecodeError) as exc:
logger.error("artifact unreadable: %s", exc)
return 2
fatals = validate_schema(data)
topo_fatals, topo_warnings = validate_topology(data)
fatals.extend(topo_fatals)
for warn in topo_warnings:
logger.warning(warn)
if fatals:
for err in fatals:
logger.error(err)
logger.error("Stage 1 FAILED: %d fatal violations", len(fatals))
return 1
logger.info("Stage 1 PASSED: schema and topology valid")
return 0
if __name__ == "__main__":
sys.exit(main())
Step 2: Routing-graph connectivity and weight integrity
Once the geometry passes, the pipeline compiles a directed routing graph and proves it is navigable. Indoor navigation depends on deterministic pathfinding, so the gate must detect isolated nodes, unreachable zones, and weight anomalies before deployment. The validator extracts transition points (doors, elevators, stairs), builds a networkx.DiGraph with traversal costs, then runs connectivity sweeps. A graph that is not weakly connected means at least one zone is stranded; non-positive weights mean a shortest-path search can loop or return nonsense.
#!/usr/bin/env python3
"""Stage 2: routing-graph connectivity and weight-integrity validator."""
import json
import logging
import sys
from pathlib import Path
from typing import Any
import networkx as nx
from shapely.geometry import shape
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger(__name__)
MAP_PATH = Path("artifacts/bldg04_floor3.geojson")
NODE_TYPES = {"door", "elevator", "stair", "corridor_node"}
def build_routing_graph(collection: dict[str, Any]) -> nx.DiGraph:
"""Construct a directed routing graph from indoor-map features."""
graph: nx.DiGraph = nx.DiGraph()
for feat in collection.get("features", []):
props = feat.get("properties", {})
if props.get("category") in NODE_TYPES:
node_id = feat.get("id")
centroid = shape(feat["geometry"]).centroid
graph.add_node(node_id, x=centroid.x, y=centroid.y,
floor_level=props.get("floor_level", 0))
for feat in collection.get("features", []):
props = feat.get("properties", {})
if props.get("edge_type") == "corridor":
src, dst = props.get("source"), props.get("target")
if src in graph.nodes and dst in graph.nodes:
weight = props.get("routing_weight", 1.0)
accessible = props.get("wheelchair_accessible", True)
graph.add_edge(src, dst, weight=weight, accessible=accessible)
if not props.get("oneway", False):
graph.add_edge(dst, src, weight=weight, accessible=accessible)
return graph
def validate_graph_integrity(graph: nx.DiGraph) -> tuple[list[str], list[str]]:
"""Check for empty graphs, disconnection, isolation, and weight anomalies."""
fatals: list[str] = []
warnings: list[str] = []
if graph.number_of_nodes() == 0:
fatals.append("GRAPH_FATAL: empty routing graph")
return fatals, warnings
if not nx.is_weakly_connected(graph):
components = list(nx.weakly_connected_components(graph))
fatals.append(f"GRAPH_FATAL: {len(components)} disconnected components")
for i, comp in enumerate(components):
warnings.append(f"GRAPH_WARN: component {i} holds {len(comp)} nodes")
isolated = [n for n, d in graph.degree() if d == 0]
if isolated:
fatals.append(f"GRAPH_FATAL: {len(isolated)} isolated nodes, e.g. {isolated[:5]}")
weights = [d.get("weight", 1.0) for _, _, d in graph.edges(data=True)]
if any(w <= 0 for w in weights):
fatals.append("GRAPH_FATAL: non-positive edge weights detected")
if weights and max(weights) > 1000.0:
warnings.append("GRAPH_WARN: edge weight exceeds 1000; verify unit conversion")
return fatals, warnings
def main() -> int:
try:
with open(MAP_PATH, "r", encoding="utf-8") as fh:
data = json.load(fh)
except (FileNotFoundError, json.JSONDecodeError) as exc:
logger.error("artifact unreadable: %s", exc)
return 2
graph = build_routing_graph(data)
fatals, warnings = validate_graph_integrity(graph)
for warn in warnings:
logger.warning(warn)
if fatals:
for err in fatals:
logger.error(err)
logger.error("Stage 2 FAILED: routing-graph integrity violations")
return 1
logger.info("Stage 2 PASSED: routing graph valid (nodes=%d, edges=%d)",
graph.number_of_nodes(), graph.number_of_edges())
return 0
if __name__ == "__main__":
sys.exit(main())
Step 3: API contract and latency verification
The final stage proves the map artifact produces consistent, performant responses from the routing engine once loaded. It issues a fixed set of origin/destination spot checks against the internal routing endpoint, validates each response body with a Pydantic model, and measures latency against an SLA threshold. Establish the baseline throughput separately with a representative load test (locust or k6); this gate is a contract check, not a load test, so it stays fast enough to run on every commit.
#!/usr/bin/env python3
"""Stage 3: API-contract and latency validator for the routing engine."""
import logging
import sys
import time
from typing import Any
import requests
from pydantic import BaseModel, ValidationError
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger(__name__)
API_BASE = "https://routing-api.internal/v1"
MAX_LATENCY_MS = 450
TEST_PAIRS = [
{"origin": "door_101", "destination": "elevator_A"},
{"origin": "room_204", "destination": "stair_B"},
{"origin": "corridor_main", "destination": "door_305"},
]
class RouteResponse(BaseModel):
status: str
path: list[dict[str, Any]]
distance_m: float
duration_s: float
metadata: dict[str, Any]
def validate_api_contract() -> list[str]:
"""Run spot-check routing requests; validate response schema and latency."""
errors: list[str] = []
for pair in TEST_PAIRS:
label = f"{pair['origin']}->{pair['destination']}"
start = time.perf_counter()
try:
resp = requests.post(
f"{API_BASE}/route",
json=pair,
headers={"Content-Type": "application/json", "X-Map-Version": "ci-test"},
timeout=5,
)
except requests.RequestException as exc:
errors.append(f"API_FAIL [{label}]: request failed: {exc}")
continue
elapsed_ms = (time.perf_counter() - start) * 1000
if resp.status_code != 200:
errors.append(f"API_FAIL [{label}]: HTTP {resp.status_code}")
continue
try:
RouteResponse.model_validate(resp.json())
except ValidationError as exc:
errors.append(f"API_FAIL [{label}]: schema mismatch: {exc}")
if elapsed_ms > MAX_LATENCY_MS:
errors.append(f"API_WARN [{label}]: latency {elapsed_ms:.0f}ms over {MAX_LATENCY_MS}ms SLA")
return errors
def main() -> int:
errors = validate_api_contract()
fatal = 0
for err in errors:
if "WARN" in err:
logger.warning(err)
else:
logger.error(err)
fatal += 1
if fatal:
logger.error("Stage 3 FAILED: %d fatal API violations", fatal)
return 1
logger.info("Stage 3 PASSED: API contract and latency within thresholds")
return 0
if __name__ == "__main__":
sys.exit(main())
Step 4: Orchestrate the gate and hand off to promotion
The three stages run inside one pipeline job that enforces sequential execution and a deterministic exit code. On success it computes the artifact’s topology_hash and packages the validated GeoJSON, which is the exact tag the downstream Cache Invalidation Strategies and Rollback Triggers & Versioning stages key on. This is what enables zero-downtime swaps: a validated artifact is promoted atomically into the routing cluster without interrupting active navigation sessions.
# .github/workflows/map-ci-gate.yml
name: Indoor Map CI Gate
on:
push:
paths: ['artifacts/**/*.geojson', 'schemas/**']
pull_request:
paths: ['artifacts/**/*.geojson']
jobs:
validate-map:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install dependencies
run: pip install jsonschema shapely networkx requests pydantic
- name: "Stage 1: schema and topology"
run: python ci/validate_stage1.py
- name: "Stage 2: routing graph"
run: python ci/validate_stage2.py
- name: "Stage 3: API contract"
run: python ci/validate_stage3.py
env:
ROUTING_API_URL: $
- name: Compute topology hash and package
if: success()
run: |
sha256sum artifacts/bldg04_floor3.geojson > artifacts/bldg04_floor3.topohash
tar czf artifacts/bldg04_floor3.tar.gz \
artifacts/bldg04_floor3.geojson artifacts/bldg04_floor3.topohash
- name: Upload validated artifact
if: success()
uses: actions/upload-artifact@v4
with:
name: validated-indoor-map
path: artifacts/bldg04_floor3.tar.gz
retention-days: 30
Edge Cases & Gotchas
The gate earns its keep on the cases that look fine in a viewer but break navigation. These are the recurring ones and how to diagnose them:
| Symptom | Root cause | Diagnostic | Remediation |
|---|---|---|---|
TOPO_FATAL: self-intersection |
CAD export introduced overlapping vertices or snapped incorrectly | shapely.wkt.dumps(geom) and inspect at geojson.io |
Run make_valid, or clean with QGIS v.clean break/snap, then re-export at reduced precision |
GRAPH_FATAL: disconnected components |
Missing transition edges, or source/target IDs that don’t match a node |
Export the graph with nx.write_graphml and open in Gephi |
Cross-reference node IDs against the facility system; add the missing door/elevator edges |
| Cross-storey edge accepted | Two features share an ID across floors with inconsistent floor_level |
Group features by floor_level and assert edge endpoints share one |
Enforce floor_level parity per Level Mapping & Z-Axis Logic before graph assembly |
API_FAIL: schema mismatch |
Engine returns legacy fields or omits the metadata block |
Diff the live response against RouteResponse |
Sync the Pydantic model and confirm the engine deployment matches the artifact’s schema_revision |
API_WARN: latency over SLA |
Dense corridor-node clusters inflate path cost | Profile Dijkstra on the hotspot floor | Prune redundant nodes and apply turn-penalty smoothing |
The unifying rule: a map that renders is not a map that routes. Every gotcha above passes a visual review and fails the gate, which is exactly the asymmetry the pipeline is built to exploit.
Validation Output
Assert on the gate’s behaviour rather than eyeballing logs. A correct artifact passes all three stages and produces a stable topology_hash; a deliberately broken one must fail at the earliest responsible stage and never reach packaging.
import subprocess
# A clean artifact: every stage returns 0.
assert subprocess.run(["python", "ci/validate_stage1.py"]).returncode == 0
assert subprocess.run(["python", "ci/validate_stage2.py"]).returncode == 0
# A graph with a removed transition edge must fail Stage 2 with code 1,
# and the orchestration must therefore never compute a topology_hash.
broken = subprocess.run(["python", "ci/validate_stage2.py"],
env={"MAP_PATH": "artifacts/broken_floor3.geojson"})
assert broken.returncode == 1
The anti-pattern to watch for is a stage that logs FATAL lines but still exits 0 — for example because a violation was appended to a list that main() never inspects. That turns the gate into theatre: it reports problems while promoting them anyway. Always assert on the exit code, because the exit code is the only thing the orchestration honours.
Performance & Scale Notes
- Stage 1 is O(n) in features and Stage 2 is O(V + E) in the graph. For a typical floor of a few thousand features both finish in well under a second; the gate stays commit-fast even on a large portfolio because each artifact is validated independently.
- Validate per floor, not per building. Sharding by
floor_levelkeeps each runner’s memory flat and lets a 40-floor airport fan out across parallel CI jobs instead of loading the whole campus into one process. - Cache the compiled schema. Re-reading and recompiling the JSON Schema per artifact dominates Stage 1 wall time when batching; compile once and reuse the
Draft202012Validatoracross files. - Keep Stage 3 a contract check. Three to five representative origin/destination pairs catch contract drift; full load testing belongs in a separate nightly job so the per-commit gate never blocks a merge on throughput.
- Make the hash the gate’s output. Computing
topology_hashonly on success means the hash is, by construction, a tag that passed every check — which is what lets Rollback Triggers & Versioning treat a revert as a pointer swap to a known-good artifact, and what keeps the degradation ladder in Fallback Routing Architectures anchored to validated graphs only.
Frequently Asked Questions
Why split validation into three stages instead of one big script?
Isolation and fail-fast economics. Each stage has a single responsibility — structure, navigability, live behaviour — so a failure points at exactly one class of defect and the runner logs are unambiguous. Sequencing them also avoids wasted work: there is no reason to load-test an API against a routing graph that Stage 2 already proved is disconnected. The stages share an artifact path and exit-code contract, so the orchestration treats them as a fail-fast chain.
Should a warning ever block promotion?
No — that is the point of the FATAL/WARNING split. A WARNING (coordinate precision beyond six decimals, an unusually large edge weight) is logged for review but does not change the exit code, so it never blocks a merge. Only FATAL violations return a non-zero code. If a warning class starts correlating with real production incidents, promote that specific check to FATAL deliberately rather than failing on all warnings.
Why key the validated artifact on a topology hash rather than a build number?
Because the hash is content-addressed: identical geometry always produces the same topology_hash, and any change produces a new one. Computing it only after all three stages pass means the hash is provably a tag that cleared every gate. That makes it reusable as the cache key for invalidation and the revert target for rollback, neither of which a monotonic build number can guarantee, since a build number says nothing about whether two artifacts are the same graph.
How do I keep the API stage from hammering production during validation?
Point Stage 3 at a dedicated internal endpoint (routing-api.internal) backed by staging, and pass the routing URL through a CI secret rather than hard-coding it. A validation burst of spot-check requests against production would otherwise trip rate limiting and skew live latency metrics. Keep the pair list small and the timeout short so the stage is a contract check, and run sustained load separately on a schedule.
Related Pages
- JSON Schema Design for Indoor Maps — the data contract Stage 1 enforces.
- Cache Invalidation Strategies — the next stage, which propagates the artifact this gate validates.
- Rollback Triggers & Versioning — content-addressed reverts keyed on the same
topology_hashthe gate computes. - SDK Integration Patterns — the clients that consume the routing graph this gate proves navigable.
This page is part of the Production-Ready Indoor Map Deployment reference; for the schema this gate checks against, see JSON Schema Design for Indoor Maps.