CI Gating for Map Updates

Indoor mapping datasets are highly interdependent; a single misaligned polygon, broken adjacency edge, or malformed routing weight can cascade into failed wayfinding sessions, inaccurate space utilization metrics, and degraded SDK performance. CI gating for map updates establishes deterministic quality barriers that intercept structural, topological, and performance regressions before they reach production environments. As a foundational component of the Production-Ready Indoor Map Deployment framework, the gating pipeline operates as a stateless validation layer that consumes map artifacts (GeoJSON, IMDF, or proprietary spatial formats), executes automated integrity checks, and emits a pass/fail signal to the promotion workflow.

The architecture follows a three-stage sequential gate: schema enforcement, graph topology validation, and API contract verification. Each stage runs in isolated CI runners, produces structured telemetry, and halts the pipeline on non-zero exit codes. Facilities teams and GIS developers should treat map updates as immutable artifacts; the CI system never mutates source geometry but instead validates against strict spatial constraints and routing invariants.

Stage 1: Schema Enforcement & Spatial Topology

Validation begins with strict adherence to the JSON Schema Design for Indoor Maps specification. Schema enforcement catches structural drift early: missing floor_id references, invalid coordinate precision, unclosed polygon rings, or mismatched CRS declarations. Topological validation extends beyond JSON structure to verify spatial relationships using computational geometry libraries like Shapely. Facilities tech teams must enforce rules such as:

  • Zero self-intersections in room boundaries
  • Complete adjacency coverage for corridor networks
  • Consistent z-index or level alignment across overlapping features
  • Valid accessibility and egress flag propagation

The validation engine parses the incoming map artifact, compiles a list of schema violations, and runs spatial topology checks. Failures are categorized as FATAL (blocks promotion) or WARNING (logged for review). All violations must include exact node/edge identifiers and coordinate references to accelerate debugging.

#!/usr/bin/env python3
"""Stage 1: Schema & Topology Validator for Indoor Map Artifacts"""
import json
import sys
import logging
from pathlib import Path
from typing import Dict, List, Tuple, Any

import jsonschema
from shapely.geometry import shape, Polygon, MultiPolygon
from shapely.validation import explain_validity

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")

SCHEMA_PATH = Path("schemas/indoor_map_v2.schema.json")
MAP_PATH = Path("artifacts/building_a_floor3.json")

def validate_schema(data: Dict[str, Any]) -> List[str]:
    """Validate against strict indoor map JSON schema."""
    with open(SCHEMA_PATH, "r") as f:
        schema = json.load(f)
    
    validator = jsonschema.Draft202012Validator(schema)
    errors = []
    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(geojson: Dict[str, Any]) -> Tuple[List[str], List[str]]:
    """Run spatial topology checks on features."""
    fatals, warnings = [], []
    
    for feat in geojson.get("features", []):
        geom = shape(feat["geometry"])
        props = feat.get("properties", {})
        fid = props.get("id", "unknown")
        
        # Check for self-intersections
        if not geom.is_valid:
            fatals.append(f"TOPO_FATAL [{fid}]: {explain_validity(geom)}")
            
        # Check coordinate precision (max 6 decimals after the point)
        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
        precision_exceeded = 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
        )
        if precision_exceeded:
            warnings.append(f"TOPO_WARN [{fid}]: Coordinate precision exceeds 6 decimals")
                
        # Check required flags for egress/accessibility
        if props.get("type") == "room" and "accessibility" not in props:
            fatals.append(f"TOPO_FATAL [{fid}]: Missing 'accessibility' flag on room feature")
            
    return fatals, warnings

def main() -> int:
    logging.info("Loading map artifact: %s", MAP_PATH)
    with open(MAP_PATH, "r") as f:
        data = json.load(f)
        
    schema_errors = validate_schema(data)
    topo_fatals, topo_warnings = validate_topology(data)
    
    all_fatals = schema_errors + topo_fatals
    for w in topo_warnings:
        logging.warning(w)
        
    if all_fatals:
        for e in all_fatals:
            logging.error(e)
        logging.error("Stage 1 FAILED: %d fatal violations detected", len(all_fatals))
        return 1
        
    logging.info("Stage 1 PASSED: Schema and topology valid")
    return 0

if __name__ == "__main__":
    sys.exit(main())

Stage 2: Wayfinding Graph & Routing Integrity

Once the spatial schema passes, the pipeline constructs a directed routing graph and verifies navigability. Indoor navigation teams rely on deterministic pathfinding; therefore, the CI gate must detect isolated nodes, unreachable zones, and weight anomalies before deployment. The validation process:

  1. Extracts navigable surfaces and transition points (doors, elevators, stairs)
  2. Builds a graph using NetworkX, assigning traversal costs based on distance, accessibility constraints, and turn penalties
  3. Runs connectivity sweeps and spot-check shortest paths to validate routing invariants

Graph validation ensures that the final artifact aligns with expected client behavior, particularly when integrated with SDK Integration Patterns that assume strongly connected components and predictable edge weights.

#!/usr/bin/env python3
"""Stage 2: Graph Connectivity & Routing Integrity Validator"""
import sys
import logging
import networkx as nx
from typing import Dict, List, Tuple, Any
from shapely.geometry import shape, Point

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")

def build_routing_graph(geojson: Dict[str, Any]) -> nx.DiGraph:
    """Construct directed graph from indoor map features."""
    G = nx.DiGraph()
    
    # Add nodes from navigable points (doors, elevators, stairs)
    for feat in geojson.get("features", []):
        props = feat.get("properties", {})
        geom = shape(feat["geometry"])
        
        if props.get("type") in ("door", "elevator", "stair", "corridor_node"):
            node_id = props["id"]
            centroid = geom.centroid
            G.add_node(node_id, x=centroid.x, y=centroid.y, level=props.get("level", 0))
            
    # Add edges based on adjacency or explicit routing hints
    for feat in geojson.get("features", []):
        props = feat.get("properties", {})
        if props.get("type") == "corridor_edge":
            src, dst = props.get("from"), props.get("to")
            if src in G.nodes and dst in G.nodes:
                weight = props.get("weight", 1.0)
                accessible = props.get("accessible", True)
                G.add_edge(src, dst, weight=weight, accessible=accessible)
                # Bidirectional unless explicitly one-way
                if not props.get("oneway", False):
                    G.add_edge(dst, src, weight=weight, accessible=accessible)
                    
    return G

def validate_graph_integrity(G: nx.DiGraph) -> Tuple[List[str], List[str]]:
    """Check for isolated nodes, disconnected components, and weight anomalies."""
    fatals, warnings = [], []
    
    if G.number_of_nodes() == 0:
        fatals.append("GRAPH_FATAL: Empty routing graph")
        return fatals, warnings
        
    # Check connectivity
    if not nx.is_weakly_connected(G):
        components = list(nx.weakly_connected_components(G))
        fatals.append(f"GRAPH_FATAL: Graph has {len(components)} disconnected components")
        for i, comp in enumerate(components):
            warnings.append(f"GRAPH_WARN: Component {i} contains {len(comp)} nodes")
            
    # Check isolated nodes (degree 0)
    isolated = [n for n, d in G.degree() if d == 0]
    if isolated:
        fatals.append(f"GRAPH_FATAL: {len(isolated)} isolated nodes detected: {isolated[:5]}...")
        
    # Check weight distribution anomalies
    weights = [data.get("weight", 1.0) for _, _, data in G.edges(data=True)]
    if any(w <= 0 for w in weights):
        fatals.append("GRAPH_FATAL: Non-positive edge weights detected")
    if max(weights) > 1000.0:
        warnings.append("GRAPH_WARN: Edge weight exceeds 1000.0; verify unit conversion")
        
    return fatals, warnings

def main() -> int:
    import json
    from pathlib import Path
    
    map_path = Path("artifacts/building_a_floor3.json")
    with open(map_path, "r") as f:
        data = json.load(f)
        
    G = build_routing_graph(data)
    fatals, warnings = validate_graph_integrity(G)
    
    for w in warnings:
        logging.warning(w)
    if fatals:
        for e in fatals:
            logging.error(e)
        logging.error("Stage 2 FAILED: Graph integrity violations")
        return 1
        
    logging.info("Stage 2 PASSED: Routing graph valid (nodes=%d, edges=%d)", G.number_of_nodes(), G.number_of_edges())
    return 0

if __name__ == "__main__":
    sys.exit(main())

Stage 3: API Contract & Performance Verification

After topology and graph validation, the pipeline verifies that the map artifact produces consistent, performant responses from the routing engine. This stage simulates production traffic patterns, validates response schemas, and measures latency against SLA thresholds. Teams should correlate these checks with Load testing indoor navigation APIs to establish baseline throughput expectations. Additionally, contract validation must account for Implementing rate limiting for indoor APIs to ensure the gating runner does not trigger false-positive throttling during validation bursts.

#!/usr/bin/env python3
"""Stage 3: API Contract & Latency Validator"""
import sys
import time
import logging
import requests
from typing import List, Dict, Any
from pydantic import BaseModel, ValidationError

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")

class RouteResponse(BaseModel):
    status: str
    path: List[Dict[str, Any]]
    distance_m: float
    duration_s: float
    metadata: Dict[str, Any]

API_BASE = "https://routing-api.internal/v1"
TEST_PAIRS = [
    {"origin": "door_101", "destination": "elevator_A"},
    {"origin": "room_204", "destination": "stair_B"},
    {"origin": "corridor_main", "destination": "door_305"}
]
MAX_LATENCY_MS = 450

def validate_api_contract() -> List[str]:
    """Run spot-check routing requests and validate response schema + latency."""
    errors = []
    
    for pair in TEST_PAIRS:
        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
            )
            elapsed_ms = (time.perf_counter() - start) * 1000
            
            if resp.status_code != 200:
                errors.append(f"API_FAIL [{pair['origin']}->{pair['destination']}]: HTTP {resp.status_code}")
                continue
                
            try:
                RouteResponse.model_validate(resp.json())
            except ValidationError as e:
                errors.append(f"API_FAIL [{pair['origin']}->{pair['destination']}]: Schema mismatch: {e}")
                
            if elapsed_ms > MAX_LATENCY_MS:
                errors.append(f"API_WARN [{pair['origin']}->{pair['destination']}]: Latency {elapsed_ms:.0f}ms exceeds {MAX_LATENCY_MS}ms SLA")
                
        except requests.RequestException as e:
            errors.append(f"API_FAIL [{pair['origin']}->{pair['destination']}]: Request failed: {e}")
            
    return errors

def main() -> int:
    errors = validate_api_contract()
    if errors:
        for e in errors:
            if "WARN" in e:
                logging.warning(e)
            else:
                logging.error(e)
        fatal_count = sum(1 for e in errors if "FAIL" in e)
        if fatal_count > 0:
            logging.error("Stage 3 FAILED: %d fatal API violations", fatal_count)
            return 1
    logging.info("Stage 3 PASSED: API contract and latency within thresholds")
    return 0

if __name__ == "__main__":
    sys.exit(main())

CI/CD Orchestration & Promotion Workflow

The three validation stages are orchestrated in a single pipeline job that enforces sequential execution, artifact caching, and deterministic exit codes. Upon successful validation, the pipeline packages the map artifact, generates a cryptographic checksum, and triggers the promotion workflow. This handoff is critical for Implementing zero-downtime map deployments, where validated artifacts are swapped atomically into the routing cluster without interrupting active navigation sessions.

# .github/workflows/map-ci-gate.yml
name: Indoor Map CI Gating
on:
  push:
    paths: ['maps/**/*.json', 'schemas/**']
  pull_request:
    paths: ['maps/**/*.json']

jobs:
  validate-map:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up Python 3.11
        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 & Topology"
        run: python ci/validate_stage1.py
      - name: "Stage 2: Graph & Routing"
        run: python ci/validate_stage2.py
      - name: "Stage 3: API Contract"
        run: python ci/validate_stage3.py
        env:
          ROUTING_API_URL: $
      - name: Generate Checksum & Package
        if: success()
        run: |
          sha256sum maps/building_a_floor3.json > maps/building_a_floor3.sha256
          tar czf maps/building_a_floor3.tar.gz maps/building_a_floor3.json maps/building_a_floor3.sha256
      - name: Upload Validated Artifact
        if: success()
        uses: actions/upload-artifact@v4
        with:
          name: validated-indoor-map
          path: maps/building_a_floor3.tar.gz
          retention-days: 30

Troubleshooting & Runbook

Symptom Likely Root Cause Remediation Steps
TOPO_FATAL: Self-intersection CAD export introduced overlapping vertices or snapped incorrectly Open source geometry in QGIS, run v.clean with break/snap tools, re-export with reduced precision
GRAPH_FATAL: Disconnected components Missing transition edges (doors/elevators) or mismatched from/to IDs Verify adjacency matrix in GIS layer, cross-reference node IDs with facility management system, add explicit routing hints
API_FAIL: Schema mismatch Routing engine returns legacy fields or missing metadata block Update RouteResponse Pydantic model, check API version header, verify engine deployment matches map spec version
API_WARN: Latency exceeds SLA Graph contains dense clusters or unoptimized edge weights Run Dijkstra profiling on hotspot zones, prune redundant corridor nodes, apply turn-penalty smoothing
Pipeline halts at Stage 1 with jsonschema.exceptions.ValidationError Schema drift between map artifact and validator Sync local schema copy with registry, run jsonschema --validate locally, check for deprecated property names

Debugging Workflow:

  1. Reproduce locally with python ci/validate_stage1.py --verbose
  2. Inspect failing geometry using shapely.wkt.dumps(geom) and paste into geojson.io
  3. For graph issues, export G to GraphML via nx.write_graphml(G, "debug.graphml") and visualize in Gephi
  4. Check CI runner logs for environment variable mismatches (e.g., ROUTING_API_URL)
  5. If warnings persist across multiple commits, file a GIS data ticket with exact coordinate bounds and node IDs