Source code for lacing.tier

"""Tiers and the five ELAN stereotypes.

A tier is a named layer of annotations. Stereotypes constrain how a tier
relates to its parent. We adopt ELAN's five stereotypes verbatim — see
ANN-DOC §C and OSS-DOC tier-2.4.
"""

from __future__ import annotations

from enum import Enum

from lacing.time import TimeInterval


[docs] class TierStereotype(str, Enum): """Constraints on how a child tier relates to its parent tier. Names match ELAN exactly so EAF round-trips are trivial. """ NONE = "NONE" """No parent constraint. Top-level tier.""" TIME_SUBDIVISION = "TIME_SUBDIVISION" """Children fully partition the parent's interval (no gaps, no overlap).""" INCLUDED_IN = "INCLUDED_IN" """Children lie within the parent but gaps between siblings are allowed.""" SYMBOLIC_SUBDIVISION = "SYMBOLIC_SUBDIVISION" """Ordered subdivision; children share parent's interval as a sequence (no times).""" SYMBOLIC_ASSOCIATION = "SYMBOLIC_ASSOCIATION" """One-to-one association with parent; child shares parent's interval exactly."""
[docs] class Tier: """A named annotation layer with optional parent and stereotype. Tiers are pure metadata; they don't own annotations. The store is keyed by interval, not by tier — annotations carry their tier name as a field. This matches ELAN's TIME_ORDER indirection (see ANN-DOC §C). """ __slots__ = ("_name", "_stereotype", "_parent", "_metadata") def __init__( self, name: str, *, stereotype: TierStereotype = TierStereotype.NONE, parent: str | None = None, metadata: dict | None = None, ) -> None: if not isinstance(name, str) or not name: raise ValueError("name must be a non-empty string") if not isinstance(stereotype, TierStereotype): raise TypeError( f"stereotype must be a TierStereotype, got {type(stereotype).__name__}" ) if parent is not None and not isinstance(parent, str): raise TypeError(f"parent must be str or None, got {type(parent).__name__}") if stereotype != TierStereotype.NONE and parent is None: raise ValueError(f"stereotype {stereotype.value} requires a parent tier") if stereotype == TierStereotype.NONE and parent is not None: raise ValueError("stereotype NONE cannot have a parent tier") object.__setattr__(self, "_name", name) object.__setattr__(self, "_stereotype", stereotype) object.__setattr__(self, "_parent", parent) object.__setattr__(self, "_metadata", dict(metadata or {})) @property def name(self) -> str: return self._name @property def stereotype(self) -> TierStereotype: return self._stereotype @property def parent(self) -> str | None: return self._parent @property def metadata(self) -> dict: # Defensive copy — Tier is intended to be value-like. return dict(self._metadata) def to_wire(self) -> dict: d: dict = { "name": self._name, "stereotype": self._stereotype.value, } if self._parent is not None: d["parent"] = self._parent if self._metadata: d["metadata"] = dict(self._metadata) return d @classmethod def from_wire(cls, d: dict) -> "Tier": return cls( d["name"], stereotype=TierStereotype(d.get("stereotype", "NONE")), parent=d.get("parent"), metadata=d.get("metadata"), ) def __eq__(self, other: object) -> bool: if not isinstance(other, Tier): return NotImplemented return ( self._name == other._name and self._stereotype == other._stereotype and self._parent == other._parent and self._metadata == other._metadata ) def __hash__(self) -> int: return hash((self._name, self._stereotype, self._parent)) def __repr__(self) -> str: return ( f"Tier({self._name!r}, stereotype={self._stereotype.value}, " f"parent={self._parent!r})" )
[docs] def validate_tier_constraint( parent: Tier | None, child: Tier, parent_intervals: list[TimeInterval], child_intervals: list[TimeInterval], ) -> list[str]: """Check that ``child_intervals`` satisfy ``child.stereotype`` against ``parent_intervals``. Returns a list of violation messages (empty list = valid). Pure function; callers decide whether to raise, log, or surface in UI. Lives here rather than on ``Tier`` so the constraint logic is testable in isolation and reusable from server validators and UI plugins alike. """ violations: list[str] = [] if child.stereotype == TierStereotype.NONE: if parent is not None: violations.append( f"tier {child.name!r} has stereotype NONE but parent={parent.name!r}" ) return violations if parent is None: violations.append( f"tier {child.name!r} stereotype {child.stereotype.value} requires a parent" ) return violations if child.parent != parent.name: violations.append( f"tier {child.name!r} declares parent={child.parent!r} " f"but was validated against parent={parent.name!r}" ) if child.stereotype == TierStereotype.INCLUDED_IN: for ci in child_intervals: if not any( pi.start <= ci.start and ci.end <= pi.end for pi in parent_intervals ): violations.append( f"child interval {ci!r} is not contained in any parent interval" ) elif child.stereotype == TierStereotype.SYMBOLIC_ASSOCIATION: # 1-1 with parent; child shares parent's interval exactly. if len(child_intervals) != len(parent_intervals): violations.append( f"SYMBOLIC_ASSOCIATION expects {len(parent_intervals)} child " f"intervals to match parent count, got {len(child_intervals)}" ) else: for pi, ci in zip(parent_intervals, child_intervals): if pi != ci: violations.append( f"SYMBOLIC_ASSOCIATION mismatch: child {ci!r} != parent {pi!r}" ) elif child.stereotype == TierStereotype.TIME_SUBDIVISION: # Children fully partition each parent: contiguous, no gaps, no overlap. # Group children by parent they fall in, then check coverage. for pi in parent_intervals: within = sorted( ( ci for ci in child_intervals if pi.start <= ci.start and ci.end <= pi.end ), key=lambda x: (x.start.to_fraction(), x.end.to_fraction()), ) if not within: violations.append(f"TIME_SUBDIVISION: parent {pi!r} has no children") continue if within[0].start != pi.start: violations.append( f"TIME_SUBDIVISION: first child {within[0]!r} does not start at {pi.start!r}" ) if within[-1].end != pi.end: violations.append( f"TIME_SUBDIVISION: last child {within[-1]!r} does not end at {pi.end!r}" ) for left, right in zip(within, within[1:]): if left.end != right.start: violations.append( f"TIME_SUBDIVISION: gap or overlap between {left!r} and {right!r}" ) elif child.stereotype == TierStereotype.SYMBOLIC_SUBDIVISION: # Symbolic — no time validation (children are an ordered sequence # within the parent's interval). We check containment only. for ci in child_intervals: if not any( pi.start <= ci.start and ci.end <= pi.end for pi in parent_intervals ): violations.append( f"SYMBOLIC_SUBDIVISION: child {ci!r} is not within any parent" ) return violations