Best Practices for Indoor POI Taxonomy

This page covers the design decision that decides whether an indoor POI taxonomy stays routable — how deep to make the hierarchy and what to express as attributes instead — and it sits under POI Taxonomy & Classification within the broader Indoor Mapping Architecture & Standards reference. The classification pipeline enforces whatever vocabulary you give it; this page is about giving it a vocabulary that does not quietly fracture the routing graph.

Concept Definition

An indoor POI taxonomy is a controlled, finite vocabulary of point-of-interest types arranged as a strict tree, where every physical space resolves to exactly one leaf and every leaf maps to one routable entity. “Best practice” here is not about being expressive — it is about three measurable properties that keep the taxonomy compatible with distance-based pathfinding:

  1. Bounded depth. A three-tier model — domain (Corporate, Healthcare, Transit), category (Circulation, Amenity, Clinical), type (Elevator, Restroom, Exam_Room) — is enough to drive routing weight, accessibility filtering, and search. Each extra tier multiplies the leaf space a pathfinder and a search index must reason over without adding routing information.
  2. Single-leaf resolution. A POI belongs to one and only one classification path. Multi-parent membership makes an A*/Dijkstra search double-count a node’s edges or fabricate phantom corridors, because the same geometry resolves to two graph nodes.
  3. Attributes, not tiers, for cross-cutting facts. Wheelchair access, badge requirements, gender-neutral status, and department ownership are tags on a leaf, never new branches. category: "Restroom" with attributes: {wheelchair_accessible: true, gender_neutral: false} stays one leaf; Accessible_Unisex_Restroom forks the tree and bloats the leaf space combinatorially.
The three tiers of the indoor POI taxonomy and how open each one is Three stacked bands. Tier one is the space class: a closed enumeration of nine values including room, corridor, door, stair, elevator and service. Tier two is the category, closed per class, with values such as office, lab, restroom, retail and meeting. Tier three is a set of open, typed key-value attributes such as an integer capacity, a boolean gendered flag and a boolean step-free flag. Only tier three may grow without a schema version bump. Closed where routing reads, open where products grow Tier 1 — class closed enum, 9 values room corridor door stair elevator service Tier 2 — category closed per class office lab restroom retail meeting Tier 3 — attributes open, typed key/value capacity: int gendered: bool step_free: bool only tier 3 may grow without a schema version bump

The openness gradient is deliberate. Routing reads tier 1 and must never encounter an unknown value; search reads tier 2; product features read tier 3, which is where new requirements land without touching the router.

The anti-pattern these rules prevent is over-classification: a taxonomy so granular that semantically identical spaces land on different leaves, so the routing graph attaches different traversal costs to the same kind of space and search relevance scatters. Over-classification rarely throws an exception — it surfaces as routing that is plausibly wrong.

Over-classified depth versus a shallow, attribute-driven taxonomy Left panel, the anti-pattern: a six-tier tree Building, Floor, Wing, Restroom fans into three near-duplicate leaves Accessible, Unisex, and Family. Tangled multi-parent edges cross between tiers, and each near-duplicate leaf binds to its own routing node, producing a fractured routing graph where the same kind of space carries different traversal costs. Right panel, best practice: a three-tier tree Corporate, Amenity, Restroom. The single Restroom leaf carries an attribute payload of wheelchair_accessible true and gender_neutral false instead of new branches, and feeds exactly one clean routing node. Same restroom, two taxonomies: where over-classification fractures routing ANTI-PATTERN — OVER-CLASSIFIED BEST PRACTICE — SHALLOW + ATTRIBUTES 6 tiers → near-duplicate leaves 3 tiers → one leaf, tagged Building Floor Wing Restroom Accessible Unisex Family n₁ n₂ n₃ 3 graph nodes, 3 traversal costs for one kind of space leaf space explodes · multi-parent forks the graph 1 2 3 DOMAIN CATEGORY TYPE Corporate Amenity Restroom ATTRIBUTES — TAGS, NOT TIERS wheelchair_accessible: true gender_neutral: false n one leaf → one routing node → one traversal cost depth ≤ 3 · single parent · facts live in attributes

Minimal Working Example

The technique that operationalizes these rules is a granularity audit: before a taxonomy is published, assert that no path exceeds the allowed depth and that no leaf has more than one parent. The self-contained routine below takes the parent/child edges of a candidate taxonomy and returns the two failure classes that break routing — paths that are too deep, and leaves that resolve under multiple parents.

import logging
from collections import defaultdict
from typing import Iterable

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

def audit_taxonomy_shape(
    edges: Iterable[tuple[str, str]], max_depth: int = 3
) -> dict[str, list[str]]:
    """Flag taxonomy leaves that are too deep or have >1 parent.

    `edges` are (parent, child) pairs; `max_depth` is the allowed tier count.
    Returns the offending leaves keyed by failure class.
    """
    parents: dict[str, list[str]] = defaultdict(list)
    try:
        for parent, child in edges:
            parents[child].append(parent)
    except ValueError as exc:  # malformed edge tuple
        logger.error("Edge list is not (parent, child) pairs: %s", exc)
        raise

    multi_parent = [c for c, ps in parents.items() if len(ps) > 1]

    def depth(node: str, seen: frozenset[str] = frozenset()) -> int:
        if node in seen:  # cycle guard
            return max_depth + 1
        ps = parents.get(node)
        return 1 if not ps else 1 + max(depth(p, seen | {node}) for p in ps)

    too_deep = [c for c in parents if depth(c) > max_depth]
    issues = {"multi_parent": multi_parent, "too_deep": too_deep}
    logger.info("Audited %d nodes: %s", len(parents), {k: len(v) for k, v in issues.items()})
    return {k: v for k, v in issues.items() if v}

Run it on the edge export before classification; a non-empty result means the design is wrong, not the data, so fix the vocabulary rather than patching individual rows downstream.

Parameter & Threshold Reference

Parameter Type Default / Threshold Notes
max_depth int 3 Tier count: domain → category → type. > 3 rarely adds routing information.
Parents per leaf int 1 (exact) More than one parent forks the node in the routing graph.
Leaves per category int ≤ 12 recommended A category exploding past ~12 leaves usually signals attributes masquerading as types.
classification_path length list[str] exactly 3, no empty element An empty trailing element passes a naive length check but breaks leaf lookup.
Cross-cutting facts attribute tags not tiers Accessibility, badge access, hours → key/value payload on the leaf.
Leaf naming str Snake_Case noun, no modifiers Restroom, not Accessible_Unisex_Restroom; modifiers belong in attributes.
routing_weight float 0.11.0 Derived from the leaf type, not encoded in its name.
Free text, a flat enum, and a three-tier taxonomy compared A comparison of three approaches to classifying indoor spaces. Free text cannot be relied on by routing, costs nothing to extend, admits typos such as Restroom, restroom and WC as three distinct values, supports only string matching for search, and grows without bound. A flat enumeration is reliable for routing and typo-proof, but every new category is a schema change and consumers must be rewritten. The three-tier taxonomy is reliable for routing, typo-proof, extends without a schema change by adding a tier-two category, supports faceted search on class, category and attributes, and grows additively. All three work on day one; two of them stop working by quarter two Property Free text Flat enum Three-tier taxonomy Routing can rely on it △ no ● yes ● yes New category costs ● nothing △ a schema change ● nothing (tier 2 add) Typos possible △ 'Restroom', 'restroom', 'WC' ● no ● no Search facets △ string matching coarse ● class + category + attrs Migration when it grows △ unbounded △ rewrite consumers ● additive A flat enum is the common first attempt; it fails on the second row within a quarter.

The flat enum fails on growth, not on correctness. It is right until the first “we also need prayer room” request, at which point every consumer that switch-cases the enum has to be redeployed together.

Common Errors & Fixes

AssertionError: multi_parent leaves = ['Elevator_Lobby'] — a space was placed under two categories (often Circulation and Amenity) to make it findable from both. The audit catches it, but the fix is design, not code: keep the single most routing-relevant parent (Circulation) and add a queryable tag (is_amenity_adjacent: true) so search still finds it without forking the graph node.

Routing weights that disagree for identical spaces — two huddle rooms resolve to Conference_Small and Huddle, so the routing graph assigns them different traversal costs even though they are the same kind of space. The root cause is leaf granularity drifting per source. Collapse near-duplicate leaves and express the distinction as an attribute:

def collapse_leaves(leaf: str, alias_map: dict[str, str]) -> str:
    """Fold near-duplicate leaf labels onto one canonical type."""
    canonical = alias_map.get(leaf.strip(), leaf.strip())
    if canonical != leaf:
        logger.info("Collapsed leaf %r -> %r", leaf, canonical)
    return canonical

# alias_map = {"Huddle": "Conference_Room", "Conf_Rm": "Conference_Room"}

KeyError on leaf lookup after a sync — a path degraded from ["Healthcare", "Clinical", "Exam_Room"] to ["Healthcare", "Exam_Room", ""] because an upstream column was dropped, and the empty trailing element slipped past a length-only check. Validate that no path element is empty, not merely that there are three of them, so the broken row is rejected at the boundary instead of crashing the leaf lookup later.

Integration Point

This design step sits upstream of everything that consumes a POI. The vocabulary you settle here is the controlled list the POI Taxonomy & Classification pipeline freezes into its pydantic validator, and the leaf types you keep shallow become the nodes the routing graph weights. Because every leaf binds to geometry, the taxonomy only behaves once each POI sits in a metric frame established by a consistent Indoor Coordinate Reference System — classify before that and the spatial within bind that gates routability passes or fails by units. Downstream, the resilient Fallback Routing Architectures assume every POI shares one taxonomy when they prune restricted edges, and the published vocabulary travels in the same FeatureCollection envelope defined by JSON Schema Design for Indoor Maps so SDKs read one shape. Wire the granularity audit into CI Gating for Map Updates so an over-classified taxonomy fails the build before it reaches a published map.

Frequently Asked Questions

How large should the tier-1 class enumeration be?

Small enough that every consumer can exhaustively handle it — around eight to ten values. Tier 1 exists so that routing can switch on space_class without a default branch, which is only safe if the set is closed and rarely changes. Every value you add is a value that every client, tile style and routing profile must learn, so the bar for admission is that routing behaviour genuinely differs: a stair and an elevator route differently and both deserve a class, whereas a meeting room and an office route identically and belong in tier 2. If you find yourself wanting an eleventh class, the question to ask is what would break if it were a tier-2 category instead; usually the answer is nothing.

What happens to categories a building uses that the taxonomy does not have?

They are quarantined, not invented. The space keeps its tier-1 class, so it stays routable and renderable, its category is set to null, and the original source string is retained alongside it for curation. That gives you three things a guess would not: the space still works, the unresolved count is a number you can gate a publish on, and the curator sees the real term rather than someone’s approximation of it. Once a term appears in several buildings it is either added to tier 2 or given an alias to an existing category, and the alias table absorbs it permanently. The anti-pattern is mapping an unknown term to the nearest-looking category at ingest time, because that guess becomes indistinguishable from a curated value the moment it is published.

Does the taxonomy need to match IMDF or IndoorGML?

It needs to map onto them cleanly, which is not the same as matching them. Publishing to Apple Maps requires IMDF’s closed category enumeration, and an internal taxonomy that mirrors IMDF exactly would be constrained by someone else’s model for every internal use as well. The practical arrangement is to keep an internal taxonomy shaped by your own routing and search needs, and maintain an explicit mapping table to each external standard at the publishing boundary — which is exactly where the Apple Maps IMDF integration does its work. What the internal taxonomy must guarantee is that such a mapping is possible: if two internal categories collapse to one external one that is fine, but an internal category with no external equivalent at all is a publishing problem you want to discover during design rather than at submission.

This page sits under the POI Taxonomy & Classification collection, part of the Indoor Mapping Architecture & Standards reference.