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 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.

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.

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