Classifier lineage: the shared most-specific-first primitive¶
Finalizing the closest-wins core of the unified trail format:
the per-classifier fallback chain is now a property of the classifier itself,
computed once in trailblaze-models, instead of a chain each caller assembles
by hand.
Background¶
The unified trail format keys each step’s recordings by device classifier
(android-phone:, ios:) and resolves them closest-wins: for the device
under test, walk from the most specific classifier up to its family and use the
first recording that matches. The waypoint schema (classifier-keyed assertions)
resolves exactly the same way — same lookup, different payload.
Until now the most-specific-first chain was caller-supplied. The unified
adapter took a List<TrailblazeDeviceClassifier> and walked it verbatim; the
ordering and completeness of that chain lived in every call site. Two problems
fell out of that:
- No single source of truth. Nothing guaranteed the chain was total, correctly ordered, or consistent between the trail adapter and the waypoint resolver — the two consumers that must agree.
- The chain didn’t actually reconstruct the on-disk vocabulary. Device
providers emit a device’s identity as a broad-first segment list
(
[ios, iphone],[android, phone]) — the same shapecomputePossibleFileNamesForDeviceClassifiersjoins with-to build legacy per-platform filenames (ios-iphone.trail.yaml). But the adapter walked those raw segments, so a recording keyed by the compound classifierios-iphone:(the vocabulary the spec and the migration use) never matched on a real device — only the bare family keyios:did. The compound recording was silently unreachable.
What we decided¶
Build the lineage as a real primitive in trailblaze-models¶
TrailblazeClassifierLineage turns a classifier into its total, ordered
(most-specific-first) ancestor chain. It’s the keystone both the trail
adapter and the waypoint schema consume, so the fallback rule is defined once.
Parent resolution for a classifier C:
- an explicit override if one is registered for
C; otherwise - the string-derived parent — drop the trailing
-segment.
android-phone-37 → android-phone → android
ios-ipad → ios
android → (root)
Derivation-based, so arbitrary depth works for free¶
String-derivation is the default because it makes arbitrary classifier depth work with no schema or parser change. A deeper classifier resolves up through every intermediate to its family purely by dropping hyphen segments:
android-phone-37 → android-phone → android
android-phone-37-xl → android-phone-37 → android-phone → android
No enum entry, no registration, no parser edit — a new sub-classifier is expressible the moment someone writes it. This is the property that lets the classifier vocabulary grow without touching the format.
Explicit parent overrides for irregular families¶
Some hardware families don’t string-derive because the family name isn’t a
prefix of its platform. A foldable hardware family that runs Android, say,
named foldable, has no -segment to drop, so by derivation alone it would be
its own root and never fall back to android. For those, a downstream module
registers an explicit parent at startup:
registerParentOverride(child = "foldable", parent = "android")
// foldable-x2 → foldable → android
Open-source classifier families (android-phone, ios-ipad, …) all
string-derive to their platform, so the override registry ships empty; it
exists for distribution-specific irregular families wired in by the
distribution that owns them. Registration happens during the device-classifier
resolution every runner does before decoding a trail, so an override is
always present before any decode that could rely on it.
Totality and the cycle guard¶
chainFor is total: it always returns a non-empty list whose first element is
the input classifier, and it always terminates. String-derivation strictly
shortens the string each step; a malformed override cycle (a → b → a) is
broken by a visited-set, so a bad registration can degrade the fallback but can
never hang resolution.
The segment → compound → chain bridge¶
resolutionChain(deviceClassifiers) takes the device’s broad-first segment list
exactly as a provider emits it, joins the segments into the compound identity,
and expands that through the lineage:
[ios, iphone] → "ios-iphone" → [ios-iphone, ios]
[android, phone]→ "android-phone"→ [android-phone, android]
[foldable, x2] → "foldable-x2" → [foldable-x2, foldable, android] (with the override above)
This is the same join computePossibleFileNamesForDeviceClassifiers already
applies for legacy filenames, so the in-file unified resolution and the legacy
filename resolution now speak the same compound vocabulary — and a recording
keyed by the compound ios-iphone: finally resolves on a real device.
Surface syntax — reaffirmed, not changed¶
Finalizing the lineage did not move the surface syntax. The classifier-keyed + closest-wins shape is the right cross-cutting pattern (the waypoint schema deliberately mirrors it), so the format keeps:
- No all-platform fallback. Selectors are platform-specific; a step needs
at least one classifier declared. Per-family sharing comes from the lineage
(
ios:covers iPhone + iPad), not from a step-level catch-all. - Family keys as the multi-device catch-all.
android/iosare the broadest classifiers; closest-wins falls through to them. recordable: false— author intent that a step is always LLM-handled.- Explicit
<classifier>: []— intentional no-op for a device class, distinct from accidental absence in the coverage report.
What changed¶
Positive:
- One definition of the fallback chain, shared by the trail adapter and the waypoint resolver — they can’t drift.
- Compound classifier recordings (
ios-iphone:) resolve correctly on real devices; the spec’s vocabulary and runtime behavior finally agree. - Arbitrary classifier depth needs no code change.
- The caller contract is simpler: pass the device’s classifier segments as the provider emits them; the lineage owns ordering, completeness, and family fallback.
Negative / watch-outs:
- The override registry is global startup state. It’s idempotent and registered on the classify-before-decode path, but a distribution that resolves classifiers through a non-standard path must ensure its overrides are registered before it decodes a trail keyed above the string-derivable level.
TrailblazeClassifierLineageis a new public surface intrailblaze-models(it feeds the TypeScript binding codegen’s source-of-truth module), so its shape is now part of the binary-compatibility baseline.