The pipeline's display rules have been unspoken promotions -- a string compare here, a file-extension inference there, each living inside one tool. internal/semconv names them: a registry of attribute keys, in the shape OpenTelemetry taught, that any producer can write and any reader can act on. The vocabulary is Atlas's own, beholden to no upstream's habits, and the registry document and the code are held to the same list by a test. The contract is deliberately asymmetric. Producers are strict -- an unknown key or a value outside its vocabulary fails the build while the mistake is one change old. Readers are lenient -- unknown keys are ignored, never refused, so a bundle written by a newer pipeline still opens in an older app. The equirectangular mapping helpers invert exactly, because a pin's packed position run backward through the declared mapping must recover the coordinates its source published.
- commit
- 63d39fd0573cdbea31ca3bd129e712b2bea04264
- parent
- c19cfc51cf071b595ce1031cea7d55bc62ac946b
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 49 | ++++ |
| A | internal/ | 351 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 156 | +++++++++++++ |
@@ -0,0 +1,49 @@
1+# Atlas semantic conventions — registry v12+3+The shared language of the pipeline: attribute keys that carry display and4+geometry meaning from a source's translator, through composition, into the5+bundle, and out to every reader. Producers are held to this vocabulary at6+build time; readers ignore what they do not know and never refuse a bundle7+over it. `semconv.go` is the executable twin of this document, and a test8+holds the two to the same list.9+10+A bundle declares the vocabulary it was written against in its manifest:11+`"conventions": 1`. The number moves only when an existing key's meaning12+breaks; new keys arrive as `experimental` and earn `stable`.13+14+## Attributes15+16+| Key | Entity | Values | Stability | Meaning |17+|---|---|---|---|---|18+| `atlas.render.as` | category | `pin` \| `text` | stable | How the category's locations draw: markers, or floating text labels. Absent, legacy `displayType` decides; absent both, `pin`. |19+| `atlas.icon.std` | category | `set/name`, e.g. `maki/mountain` | stable | Standard-library icon for a category without artwork. Resolved to embedded bytes at build time; the app only sees the resolved asset. |20+| `atlas.icon.kind` | category | `glyph` \| `picture` | stable | Whether the icon asset is a monochrome glyph the viewer tints, or a picture drawn as-is. Names what the `.png` suffix used to imply. |21+| `atlas.icon.outset` | map | `light` \| `dark` | stable | The rim a map's markers wear to stay legible against its art. |22+| `atlas.geometry.surface` | map | `plane` \| `sphere` | stable | What the raster pictures. Absent means `plane` — every map before the planets. |23+| `atlas.geometry.projection` | map | `equirect` | stable | How a sphere was flattened. Required when surface is `sphere`. |24+| `atlas.geometry.equirect.px` | map | `x,y,w,h` (world px) | stable | The raster window the projection fills. |25+| `atlas.geometry.equirect.deg` | map | `west,north,east,south` (degrees) | stable | The ground that window pictures. |26+| `atlas.geometry.body` | map | slug, e.g. `mars` | experimental | The body pictured. |27+| `atlas.geometry.radius_km` | map | decimal string | experimental | The body's mean radius. |28+| `atlas.geo.lat` | location | decimal degrees | experimental | True planetary latitude as published (planetocentric). Provenance and card material; rendering derives from the map-level mapping. |29+| `atlas.geo.lon` | location | decimal degrees | experimental | True planetary longitude as published (east-positive). |30+| `atlas.category.key` | category | slug | experimental | A category's merge identity across sources. Absent, the icon key stands in. |31+32+## Policy names (never written to a payload)33+34+| Name | Meaning |35+|---|---|36+| `atlas.note.text` | A pin's description, as the merge policy table and ledger name it, so "which description wins" is decided and recorded in the same vocabulary as everything else. |37+38+## Rules39+40+- Producer-side: any `atlas.*` key not in this registry, attached to the41+ wrong entity, or carrying a value outside its vocabulary **fails the42+ build**.43+- Reader-side: unknown keys are ignored. Bundles are never refused over44+ conventions.45+- `sphere` requires `projection` and both `equirect.*` mappings; the46+ declared mapping must invert — a pin's packed position run backward47+ through it recovers the source's published coordinates.48+- `atlas.icon.std`, once declared, must be resolved to a real icon asset by49+ the time the bundle is written.@@ -0,0 +1,351 @@
1+// Package semconv is the registry of Atlas's semantic conventions: the2+// attribute keys that carry display and geometry meaning through the3+// pipeline as a shared language, the way OpenTelemetry's semantic4+// conventions carry meaning between systems that never met.5+//6+// The conventions are Atlas's own. They exist precisely so the format is7+// beholden to no upstream's habits -- not MapGenie's category grammar, not8+// the games' assumptions, not the sciences' tiling -- and every rule that9+// used to be an unspoken promotion inside one tool becomes a named key any10+// producer can write and any reader can act on.11+//12+// The contract has two sides, deliberately asymmetric. Producers are strict:13+// an attribute in the atlas namespace that the registry does not know, or a14+// value outside its vocabulary, fails the build while the mistake is one15+// change old. Readers are lenient: an unknown key is ignored, never refused,16+// so a bundle written by a newer pipeline still opens in an older app. New17+// keys arrive with experimental stability and earn stable; only a breaking18+// change to an existing vocabulary would move Version.19+package semconv20+21+import (22+ "fmt"23+ "slices"24+ "sort"25+ "strconv"26+ "strings"27+)28+29+// Version names the vocabulary a bundle was written against. It rides the30+// manifest as "conventions" and moves only when an existing key's meaning31+// breaks -- additions ride on per-key stability instead.32+const Version = 133+34+// Entity is what an attribute attaches to.35+type Entity string36+37+const (38+ EntityBundle Entity = "bundle"39+ EntityMap Entity = "map"40+ EntityCategory Entity = "category"41+ EntityLocation Entity = "location"42+)43+44+// Stability says how settled a key is. Experimental keys may still change45+// spelling or vocabulary; stable keys only break with a Version move.46+type Stability string47+48+const (49+ Stable Stability = "stable"50+ Experimental Stability = "experimental"51+)52+53+// The registered keys.54+const (55+ // RenderAs says how a category's locations are drawn: as markers or as56+ // floating text labels. It replaces the pipeline's single unspoken57+ // display rule, the string compare on MapGenie's display_type.58+ KeyRenderAs = "atlas.render.as"59+60+ // IconStd names a standard-library icon for a category that has no61+ // artwork of its own, as set/name, e.g. "maki/mountain". The ingestion62+ // pipeline resolves it to embedded bytes; the app only ever sees the63+ // resolved asset.64+ KeyIconStd = "atlas.icon.std"65+66+ // IconKind says whether a category's icon asset is a monochrome glyph67+ // the viewer tints, or a picture drawn as-is. It names what used to be68+ // inferred from the asset's file extension.69+ KeyIconKind = "atlas.icon.kind"70+71+ // IconOutset says which rim a map's markers wear so they stay legible72+ // against its art: the light rim of a dark map or the dark rim of a73+ // light one.74+ KeyIconOutset = "atlas.icon.outset"75+76+ // GeometrySurface declares what the map's raster is a picture of: a77+ // plane, which every map was until a planet arrived, or a sphere. A map78+ // that says nothing is a plane.79+ KeyGeometrySurface = "atlas.geometry.surface"80+81+ // GeometryProjection says how a spherical surface was flattened into82+ // the raster. Equirectangular is the only vocabulary so far.83+ KeyGeometryProjection = "atlas.geometry.projection"84+85+ // GeometryEquirectPx is the raster window the projection fills, in86+ // world pixels, as "x,y,w,h".87+ KeyGeometryEquirectPx = "atlas.geometry.equirect.px"88+89+ // GeometryEquirectDeg is the ground that window pictures, in degrees,90+ // as "west,north,east,south".91+ KeyGeometryEquirectDeg = "atlas.geometry.equirect.deg"92+93+ // GeometryBody names the body pictured, e.g. "mars".94+ KeyGeometryBody = "atlas.geometry.body"95+96+ // GeometryRadiusKM is the body's mean radius in kilometers, as a97+ // decimal string.98+ KeyGeometryRadiusKM = "atlas.geometry.radius_km"99+100+ // GeoLat and GeoLon carry a location's true planetary coordinates as101+ // the source published them: planetocentric degrees, east-positive102+ // longitude. They are provenance and card material; rendering derives103+ // positions from the map-level mapping instead.104+ KeyGeoLat = "atlas.geo.lat"105+ KeyGeoLon = "atlas.geo.lon"106+107+ // CategoryKey is a category's merge identity: the slug categories from108+ // different sources meet under when they mean the same concept. Absent,109+ // the icon key stands in, which is today's behavior named.110+ KeyCategoryKey = "atlas.category.key"111+112+ // NoteText never appears in a payload: it is the name the merge policy113+ // table and ledger use for a pin's description, so "which description114+ // wins" is decided and recorded in the same vocabulary as everything115+ // else.116+ KeyNoteText = "atlas.note.text"117+)118+119+// Vocabulary values.120+const (121+ RenderAsPin = "pin"122+ RenderAsText = "text"123+124+ IconKindGlyph = "glyph"125+ IconKindPicture = "picture"126+127+ OutsetLight = "light"128+ OutsetDark = "dark"129+130+ SurfacePlane = "plane"131+ SurfaceSphere = "sphere"132+133+ ProjectionEquirect = "equirect"134+)135+136+// definition is one registered key: where it attaches, how settled it is,137+// and what values it admits.138+type definition struct {139+ entity Entity140+ stability Stability141+ check func(value string) error142+}143+144+func enum(values ...string) func(string) error {145+ return func(value string) error {146+ if slices.Contains(values, value) {147+ return nil148+ }149+ return fmt.Errorf("%q is not one of %s", value, strings.Join(values, "|"))150+ }151+}152+153+func slug(value string) error {154+ if value == "" {155+ return fmt.Errorf("empty")156+ }157+ for _, r := range value {158+ if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_' {159+ continue160+ }161+ return fmt.Errorf("%q is not a slug", value)162+ }163+ return nil164+}165+166+func decimal(value string) error {167+ if _, err := strconv.ParseFloat(value, 64); err != nil {168+ return fmt.Errorf("%q is not a number", value)169+ }170+ return nil171+}172+173+func setName(value string) error {174+ set, name, found := strings.Cut(value, "/")175+ if !found || set == "" || name == "" {176+ return fmt.Errorf("%q is not set/name", value)177+ }178+ if err := slug(set); err != nil {179+ return err180+ }181+ return slug(name)182+}183+184+func numbers(count int) func(string) error {185+ return func(value string) error {186+ parts := strings.Split(value, ",")187+ if len(parts) != count {188+ return fmt.Errorf("%q wants %d comma-separated numbers", value, count)189+ }190+ for _, part := range parts {191+ if _, err := strconv.ParseFloat(part, 64); err != nil {192+ return fmt.Errorf("%q is not a number", part)193+ }194+ }195+ return nil196+ }197+}198+199+// registry is the whole vocabulary. REGISTRY.md is its prose twin, and a200+// test holds the two to the same list of keys.201+var registry = map[string]definition{202+ KeyRenderAs: {EntityCategory, Stable, enum(RenderAsPin, RenderAsText)},203+ KeyIconStd: {EntityCategory, Stable, setName},204+ KeyIconKind: {EntityCategory, Stable, enum(IconKindGlyph, IconKindPicture)},205+ KeyIconOutset: {EntityMap, Stable, enum(OutsetLight, OutsetDark)},206+ KeyGeometrySurface: {EntityMap, Stable, enum(SurfacePlane, SurfaceSphere)},207+ KeyGeometryProjection: {EntityMap, Stable, enum(ProjectionEquirect)},208+ KeyGeometryEquirectPx: {EntityMap, Stable, numbers(4)},209+ KeyGeometryEquirectDeg: {EntityMap, Stable, numbers(4)},210+ KeyGeometryBody: {EntityMap, Experimental, slug},211+ KeyGeometryRadiusKM: {EntityMap, Experimental, decimal},212+ KeyGeoLat: {EntityLocation, Experimental, decimal},213+ KeyGeoLon: {EntityLocation, Experimental, decimal},214+ KeyCategoryKey: {EntityCategory, Experimental, slug},215+}216+217+// Keys lists every registered key in a stable order, for the tests and the218+// tools that report on adoption.219+func Keys() []string {220+ out := make([]string, 0, len(registry))221+ for key := range registry {222+ out = append(out, key)223+ }224+ sort.Strings(out)225+ return out226+}227+228+// EntityOf reports where a key attaches, for tools walking a payload.229+func EntityOf(key string) (Entity, bool) {230+ definition, known := registry[key]231+ return definition.entity, known232+}233+234+// StabilityOf reports how settled a key is.235+func StabilityOf(key string) (Stability, bool) {236+ definition, known := registry[key]237+ return definition.stability, known238+}239+240+// Validate holds one entity's attributes to the registry: every key in the241+// atlas namespace must be registered, attached to this entity, and carry a242+// value its vocabulary admits. Keys outside the namespace are not this243+// registry's business and pass through -- but nothing in the pipeline244+// writes any today.245+func Validate(entity Entity, attrs map[string]string) error {246+ for _, key := range sortedKeys(attrs) {247+ if !strings.HasPrefix(key, "atlas.") {248+ continue249+ }250+ definition, known := registry[key]251+ if !known {252+ return fmt.Errorf("attribute %q is not registered", key)253+ }254+ if definition.entity != entity {255+ return fmt.Errorf("attribute %q attaches to a %s, not a %s", key, definition.entity, entity)256+ }257+ if err := definition.check(attrs[key]); err != nil {258+ return fmt.Errorf("attribute %q: %w", key, err)259+ }260+ }261+ return nil262+}263+264+// RenderAs answers how a category draws, from its attributes first and the265+// legacy display_type field when no attribute speaks. This is the one rule266+// the viewer used to hold as a string compare, spelled once for every267+// consumer: text is text, and everything else is a pin.268+func RenderAs(attrs map[string]string, legacyDisplayType string) string {269+ if value, declared := attrs[KeyRenderAs]; declared {270+ return value271+ }272+ if legacyDisplayType == "text" {273+ return RenderAsText274+ }275+ return RenderAsPin276+}277+278+// Equirect is a declared flattening of a sphere: the raster window it fills279+// in world pixels, and the ground that window pictures in degrees. West may280+// exceed east numerically (a map centered on the antimeridian); the span281+// still reads west-to-east.282+type Equirect struct {283+ X, Y, W, H float64284+ West, North, East, South float64285+}286+287+// ParseEquirect reads the two mapping attributes into one transform,288+// refusing windows with no area.289+func ParseEquirect(px, deg string) (Equirect, error) {290+ pxParts, err := floats(px, 4)291+ if err != nil {292+ return Equirect{}, fmt.Errorf("equirect px: %w", err)293+ }294+ degParts, err := floats(deg, 4)295+ if err != nil {296+ return Equirect{}, fmt.Errorf("equirect deg: %w", err)297+ }298+ e := Equirect{299+ X: pxParts[0], Y: pxParts[1], W: pxParts[2], H: pxParts[3],300+ West: degParts[0], North: degParts[1], East: degParts[2], South: degParts[3],301+ }302+ if e.W <= 0 || e.H <= 0 {303+ return Equirect{}, fmt.Errorf("equirect window %q has no area", px)304+ }305+ if e.North == e.South || e.West == e.East {306+ return Equirect{}, fmt.Errorf("equirect ground %q has no extent", deg)307+ }308+ return e, nil309+}310+311+// Apply lands true coordinates on the raster: degrees in, world pixels out.312+func (e Equirect) Apply(lat, lon float64) (x, y float64) {313+ x = e.X + (lon-e.West)/(e.East-e.West)*e.W314+ y = e.Y + (e.North-lat)/(e.North-e.South)*e.H315+ return x, y316+}317+318+// Invert recovers true coordinates from the raster: world pixels in,319+// degrees out. Every pin's packed position runs back through this to stand320+// on the sphere, so the mapping declared on the map is the whole story and321+// no pin needs to carry its own.322+func (e Equirect) Invert(x, y float64) (lat, lon float64) {323+ lon = e.West + (x-e.X)/e.W*(e.East-e.West)324+ lat = e.North - (y-e.Y)/e.H*(e.North-e.South)325+ return lat, lon326+}327+328+func floats(value string, count int) ([]float64, error) {329+ parts := strings.Split(value, ",")330+ if len(parts) != count {331+ return nil, fmt.Errorf("%q wants %d numbers", value, count)332+ }333+ out := make([]float64, count)334+ for at, part := range parts {335+ parsed, err := strconv.ParseFloat(strings.TrimSpace(part), 64)336+ if err != nil {337+ return nil, fmt.Errorf("%q is not a number", part)338+ }339+ out[at] = parsed340+ }341+ return out, nil342+}343+344+func sortedKeys(attrs map[string]string) []string {345+ out := make([]string, 0, len(attrs))346+ for key := range attrs {347+ out = append(out, key)348+ }349+ sort.Strings(out)350+ return out351+}@@ -0,0 +1,156 @@
1+package semconv2+3+import (4+ "math"5+ "os"6+ "regexp"7+ "strings"8+ "testing"9+)10+11+func TestValidateHoldsTheVocabulary(t *testing.T) {12+ good := []struct {13+ entity Entity14+ attrs map[string]string15+ }{16+ {EntityCategory, map[string]string{KeyRenderAs: "pin"}},17+ {EntityCategory, map[string]string{KeyRenderAs: "text", KeyIconStd: "maki/mountain"}},18+ {EntityCategory, map[string]string{KeyIconKind: "glyph", KeyCategoryKey: "ripperdoc"}},19+ {EntityMap, map[string]string{20+ KeyGeometrySurface: "sphere",21+ KeyGeometryProjection: "equirect",22+ KeyGeometryEquirectPx: "0,0,8192,4096",23+ KeyGeometryEquirectDeg: "-180,90,180,-90",24+ KeyGeometryBody: "mars",25+ KeyGeometryRadiusKM: "3389.5",26+ }},27+ {EntityMap, map[string]string{KeyIconOutset: "dark"}},28+ {EntityLocation, map[string]string{KeyGeoLat: "-42.4301", KeyGeoLon: "70.5025"}},29+ // Empty is always fine: conventions are declared, never demanded.30+ {EntityCategory, nil},31+ }32+ for _, test := range good {33+ if err := Validate(test.entity, test.attrs); err != nil {34+ t.Errorf("%s %v refused: %v", test.entity, test.attrs, err)35+ }36+ }37+38+ bad := []struct {39+ entity Entity40+ attrs map[string]string41+ mention string42+ }{43+ {EntityCategory, map[string]string{"atlas.render.like": "pin"}, "not registered"},44+ {EntityCategory, map[string]string{KeyRenderAs: "zone"}, "not one of"},45+ {EntityMap, map[string]string{KeyRenderAs: "pin"}, "attaches to"},46+ {EntityCategory, map[string]string{KeyIconStd: "mountain"}, "set/name"},47+ {EntityCategory, map[string]string{KeyIconStd: "Maki/Mountain"}, "slug"},48+ {EntityMap, map[string]string{KeyGeometryEquirectPx: "0,0,8192"}, "4"},49+ {EntityMap, map[string]string{KeyGeometryRadiusKM: "big"}, "number"},50+ {EntityLocation, map[string]string{KeyGeoLat: "north"}, "number"},51+ // The policy name never rides a payload.52+ {EntityLocation, map[string]string{KeyNoteText: "words"}, "not registered"},53+ }54+ for _, test := range bad {55+ err := Validate(test.entity, test.attrs)56+ if err == nil || !strings.Contains(err.Error(), test.mention) {57+ t.Errorf("%s %v: %v, wanted mention of %q", test.entity, test.attrs, err, test.mention)58+ }59+ }60+}61+62+func TestRenderAsPrefersAttributesOverLegacy(t *testing.T) {63+ cases := []struct {64+ attrs map[string]string65+ legacy string66+ want string67+ }{68+ {map[string]string{KeyRenderAs: "text"}, "markers", "text"},69+ {map[string]string{KeyRenderAs: "pin"}, "text", "pin"},70+ {nil, "text", "text"},71+ {nil, "markers", "pin"},72+ {nil, "", "pin"},73+ }74+ for _, test := range cases {75+ if got := RenderAs(test.attrs, test.legacy); got != test.want {76+ t.Errorf("RenderAs(%v, %q) = %q, want %q", test.attrs, test.legacy, got, test.want)77+ }78+ }79+}80+81+// TestEquirectRoundTrip drives the Mars mapping both ways: the declared82+// window is the top half of the 8192-pixel world, and every corner and83+// famous place must survive there and back within floating-point noise.84+func TestEquirectRoundTrip(t *testing.T) {85+ mapping, err := ParseEquirect("0,0,8192,4096", "-180,90,180,-90")86+ if err != nil {87+ t.Fatal(err)88+ }89+ places := [][2]float64{90+ {90, -180}, {90, 180}, {-90, -180}, {-90, 180}, {0, 0},91+ {18.6528, -133.8025}, // Olympus Mons92+ {-42.4301, 70.5025}, // Hellas Planitia93+ {-14.0059, -58.5877}, // Valles Marineris94+ }95+ for _, place := range places {96+ x, y := mapping.Apply(place[0], place[1])97+ lat, lon := mapping.Invert(x, y)98+ if math.Abs(lat-place[0]) > 1e-9 || math.Abs(lon-place[1]) > 1e-9 {99+ t.Errorf("%v round-trips to %v,%v via %v,%v", place, lat, lon, x, y)100+ }101+ }102+ // And the anchor the whole design stands on: Olympus Mons lands on the103+ // same pixel the trekmap translator computes.104+ x, y := mapping.Apply(18.6528, -133.8025)105+ wantX := (-133.8025 + 180) / 360 * 8192106+ wantY := (90 - 18.6528) / 180 * 4096107+ if math.Abs(x-wantX) > 1e-9 || math.Abs(y-wantY) > 1e-9 {108+ t.Errorf("Olympus Mons at %v,%v, want %v,%v", x, y, wantX, wantY)109+ }110+}111+112+func TestParseEquirectRefusesEmptyWindows(t *testing.T) {113+ cases := [][2]string{114+ {"0,0,0,4096", "-180,90,180,-90"},115+ {"0,0,8192,4096", "-180,90,-180,-90"},116+ {"0,0,8192", "-180,90,180,-90"},117+ }118+ for _, test := range cases {119+ if _, err := ParseEquirect(test[0], test[1]); err == nil {120+ t.Errorf("ParseEquirect(%q, %q) accepted", test[0], test[1])121+ }122+ }123+}124+125+// TestRegistryAgreesWithItsDocument holds semconv.go and REGISTRY.md to the126+// same vocabulary: every registered key appears in the document's attribute127+// table with the same entity and stability, and the document names no128+// attribute the registry does not know.129+func TestRegistryAgreesWithItsDocument(t *testing.T) {130+ doc, err := os.ReadFile("REGISTRY.md")131+ if err != nil {132+ t.Fatal(err)133+ }134+ row := regexp.MustCompile("(?m)^\\| `(atlas\\.[a-z_.]+)` \\| (bundle|map|category|location) \\|.*\\| (stable|experimental) \\|")135+ documented := make(map[string][2]string)136+ for _, match := range row.FindAllStringSubmatch(string(doc), -1) {137+ documented[match[1]] = [2]string{match[2], match[3]}138+ }139+ for _, key := range Keys() {140+ entry, found := documented[key]141+ if !found {142+ t.Errorf("%s is registered but not documented", key)143+ continue144+ }145+ entity, _ := EntityOf(key)146+ stability, _ := StabilityOf(key)147+ if entry[0] != string(entity) || entry[1] != string(stability) {148+ t.Errorf("%s documented as %s/%s, registered as %s/%s",149+ key, entry[0], entry[1], entity, stability)150+ }151+ delete(documented, key)152+ }153+ for key := range documented {154+ t.Errorf("%s is documented but not registered", key)155+ }156+}