clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

semconv: registry v2 -- collections and features

Dami committed b8595f2
The registry spoke MapGenie's ontology: a zone, a category, a
location, three entities for what the unified format knows as two.
v2 collapses them into collection and feature -- every named set of
things on a map is a collection, everything in one is a feature --
and moves each key to the entity it always described. Icon, render
and stroke keys attach to the collection; coordinates and watershed
membership attach to the feature; the world keys do not move. Two
keys arrive for the unified wire to speak later: atlas.geometry.kind,
stable, declaring point, path or area once per collection; and
atlas.label.policy, experimental, letting an area collection choose
quiet names over always-on ones. One key is renamed with its entity:
atlas.category.key becomes atlas.collection.key, which is the sole
payload byte change and touches only Cyberpunk 2077, the one game
with curated category equivalents -- no reader looks the old key up.

The wire itself has not moved, so two allowances bridge to flag day.
A v2 zone still spells its path collection's stroke width on itself,
and the validators let the zone answer for its collection's one
attribute until the write moves where the registry points. And the
manifest keeps declaring conventions 1: the number names the
vocabulary the payloads actually speak, and stamping the registry's
own version would have drifted every bundle's identity over words no
payload says yet. A rebuilt Westminster bundle lands under the same
stamp, byte for byte.

REGISTRY.md is rewritten as the v2 document, and the parity test
holds it to the new entity names.
commit
b8595f20a1749ae8b0c7c87ca55c4a158b320478
parent
37327fa0fa66c5c50b789baac693355d8715dd0f
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 2.1 of 3, Jev's estimate
8 files changed, +175 -81
Minternal/arcgismap/arcgismap_test.go17++++--
Minternal/bundle/read.go24++++++-
Minternal/semconv/REGISTRY.md28+++++---
Minternal/semconv/semconv.go99+++++++++++++++++++-----------
Minternal/semconv/semconv_test.go33++++++----
Mtools/generate/bundles.go12++-
Mtools/generate/main.go33++++++++--
Mtools/generate/merge.go10++--
M internal/arcgismap/arcgismap_test.go +12 -5
@@ -207,7 +207,7 @@ func TestTranslateShapesTheDocument(t *testing.T) {
207207 if historic.Title != "Historic Resources" || len(historic.Locations) != 2 {208208 t.Fatalf("category is %q with %d pins", historic.Title, len(historic.Locations))209209 }210- if err := semconv.Validate(semconv.EntityCategory, historic.Attrs); err != nil {210+ if err := semconv.Validate(semconv.EntityCollection, historic.Attrs); err != nil {211211 t.Fatalf("category attrs: %v", err)212212 }213213 pin := historic.Locations[0]
@@ -217,7 +217,7 @@ func TestTranslateShapesTheDocument(t *testing.T) {
217217 if pin.Description != "Residential · 42 NW Hawthorne Ave · Constructed: 1910" {218218 t.Fatalf("pin card reads %q", pin.Description)219219 }220- if err := semconv.Validate(semconv.EntityLocation, pin.Attrs); err != nil {220+ if err := semconv.Validate(semconv.EntityFeature, pin.Attrs); err != nil {221221 t.Fatalf("pin attrs: %v", err)222222 }223223 if pin.Attrs[semconv.KeyGeoLat] != "44.0581" || pin.Attrs[semconv.KeyGeoLon] != "-121.3081" {
@@ -263,7 +263,7 @@ func TestTranslateShapesTheDocument(t *testing.T) {
263263 if pf.Description != "Lies in the Tumalo Creek subwatershed (HUC 170703010101)." {264264 t.Fatalf("PF card reads %q", pf.Description)265265 }266- if err := semconv.Validate(semconv.EntityZone, pf.Attrs); err != nil {266+ if err := semconv.Validate(semconv.EntityFeature, pf.Attrs); err != nil {267267 t.Fatalf("PF attrs: %v", err)268268 }269269 if pf.Attrs[semconv.KeyHydroHUC12] != "170703010101" {
@@ -294,8 +294,15 @@ func TestTranslateShapesTheDocument(t *testing.T) {
294294 if trail.Subtitle != "Riley Ranch Nature Reserve" || len(trail.Features) != 1 {295295 t.Fatalf("trail zone is %+v", trail)296296 }297- // A line zone stays the line it is, and declares the width it is drawn at.298- if err := semconv.Validate(semconv.EntityZone, trail.Attrs); err != nil {297+ // A line zone stays the line it is, and declares the width it is drawn298+ // at. The v2 wire spells the collection's stroke on the zone, so its two299+ // keys validate under the entities the registry attaches them to.300+ if err := semconv.Validate(semconv.EntityCollection,301+ map[string]string{semconv.KeyStrokeWidthPx: trail.Attrs[semconv.KeyStrokeWidthPx]}); err != nil {302+ t.Fatalf("trail stroke: %v", err)303+ }304+ if err := semconv.Validate(semconv.EntityFeature,305+ map[string]string{semconv.KeyHydroHUC12: trail.Attrs[semconv.KeyHydroHUC12]}); err != nil {299306 t.Fatalf("trail attrs: %v", err)300307 }301308 if trail.Attrs[semconv.KeyStrokeWidthPx] != "12" {
M internal/bundle/read.go +21 -3
@@ -258,13 +258,31 @@ func validateConventions(slug string, peek payloadPeek, text []byte) error {
258258 }259259 }260260 for _, zone := range peek.Zones {261- if err := semconv.Validate(semconv.EntityZone, zone.Attrs); err != nil {261+ // A zone's attributes attach to the feature it is -- except its262+ // stroke width, which the v2 wire spells on each zone though the263+ // registry attaches it to the collection. The zone answers for its264+ // collection until the unified wire moves the write.265+ attrs := zone.Attrs266+ if width, carried := attrs[semconv.KeyStrokeWidthPx]; carried {267+ if err := semconv.Validate(semconv.EntityCollection,268+ map[string]string{semconv.KeyStrokeWidthPx: width}); err != nil {269+ return fmt.Errorf("world %s: %w", slug, err)270+ }271+ rest := make(map[string]string, len(attrs)-1)272+ for key, value := range attrs {273+ if key != semconv.KeyStrokeWidthPx {274+ rest[key] = value275+ }276+ }277+ attrs = rest278+ }279+ if err := semconv.Validate(semconv.EntityFeature, attrs); err != nil {262280 return fmt.Errorf("world %s: %w", slug, err)263281 }264282 }265283 for _, group := range peek.Groups {266284 for _, category := range group.Categories {267- if err := semconv.Validate(semconv.EntityCategory, category.Attrs); err != nil {285+ if err := semconv.Validate(semconv.EntityCollection, category.Attrs); err != nil {268286 return fmt.Errorf("world %s: %w", slug, err)269287 }270288 if declared, ok := category.Attrs[semconv.KeyRenderAs]; ok {
@@ -283,7 +301,7 @@ func validateConventions(slug string, peek payloadPeek, text []byte) error {
283301 return fmt.Errorf("world %s: decode text: %w", slug, err)284302 }285303 for id, entry := range entries {286- if err := semconv.Validate(semconv.EntityLocation, entry.Attrs); err != nil {304+ if err := semconv.Validate(semconv.EntityFeature, entry.Attrs); err != nil {287305 return fmt.Errorf("world %s pin %s: %w", slug, id, err)288306 }289307 }
M internal/semconv/REGISTRY.md +17 -11
@@ -1,4 +1,4 @@
1-# Atlas semantic conventions — registry v11+# Atlas semantic conventions — registry v222 33 The shared language of the pipeline: attribute keys that carry display and44 geometry meaning from a source's translator, through composition, into the
@@ -8,16 +8,22 @@ over it. `semconv.go` is the executable twin of this document, and a test
88 holds the two to the same list.99 1010 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`.11+`"conventions": 2`. The number moves only when an existing key's meaning12+breaks; new keys arrive as `experimental` and earn `stable`. v2 is such a13+break: it collapsed the v1 entities `zone`, `category`, and `location` into14+`collection` and `feature` when the format unified them — every kind of15+thing on a map is a feature, every named set of them a collection — and16+renamed `atlas.category.key` to `atlas.collection.key` to match.1317 1418 ## Attributes1519 1620 | Key | Entity | Values | Stability | Meaning |1721 |---|---|---|---|---|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. |22+| `atlas.geometry.kind` | collection | `point` \| `path` \| `area` | stable | What shape of thing the collection holds. Declared once per collection; every feature in it is that kind, and readers pick rendering and UX by this key instead of sniffing geometry types. |23+| `atlas.label.policy` | collection | `always` \| `quiet` | experimental | Whether an area collection's features wear their names on the map always, or quietly — only on highlight, selection, or an explicit reveal. Area collections only; absent means `always` for areas (today's look), and paths are always quiet. |24+| `atlas.render.as` | collection | `pin` \| `text` | stable | How a point collection's features draw: markers, or floating text labels. Absent, legacy `displayType` decides; absent both, `pin`. |25+| `atlas.icon.std` | collection | `set/name`, e.g. `maki/mountain` | stable | Standard-library icon for a collection without artwork. Resolved to embedded bytes at build time; the app only sees the resolved asset. |26+| `atlas.icon.kind` | collection | `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. |2127 | `atlas.icon.outset` | world | `light` \| `dark` | stable | The rim a map's markers wear to stay legible against its art. |2228 | `atlas.geometry.surface` | world | `plane` \| `sphere` | stable | What the raster pictures. Absent means `plane` — every map before the planets. |2329 | `atlas.geometry.projection` | world | `equirect` | stable | How a sphere was flattened. Required when surface is `sphere`. |
@@ -27,11 +33,11 @@ breaks; new keys arrive as `experimental` and earn `stable`.
2733 | `atlas.geometry.mercator.deg` | world | `west,north,east,south` (degrees) | experimental | The ground the Mercator window pictures, in degrees at its edges. |2834 | `atlas.geometry.body` | world | slug, e.g. `mars` | experimental | The body pictured. |2935 | `atlas.geometry.radius_km` | world | decimal string | experimental | The body's mean radius. |30-| `atlas.geo.lat` | location | decimal degrees | experimental | True planetary latitude as published (planetocentric). Provenance and card material; rendering derives from the map-level mapping. |31-| `atlas.geo.lon` | location | decimal degrees | experimental | True planetary longitude as published (east-positive). |32-| `atlas.hydro.huc12` | zone | twelve digits | experimental | The USGS twelve-digit hydrologic unit — the subwatershed — the zone's ground lies wholly within, from the national Watershed Boundary Dataset. A zone spanning subwatersheds carries no key rather than a misleading one. |33-| `atlas.stroke.width_px` | zone | positive decimal (world px) | experimental | The ground width of a zone whose features are lines: a trail is a line and a weight, and the weight lets a reader draw the path as one continuous stroke. |34-| `atlas.category.key` | category | slug | experimental | A category's merge identity across sources. Absent, the icon key stands in. |36+| `atlas.geo.lat` | feature | decimal degrees | experimental | True planetary latitude as published (planetocentric). Provenance and card material; rendering derives from the map-level mapping. |37+| `atlas.geo.lon` | feature | decimal degrees | experimental | True planetary longitude as published (east-positive). |38+| `atlas.hydro.huc12` | feature | twelve digits | experimental | The USGS twelve-digit hydrologic unit — the subwatershed — the feature's ground lies wholly within, from the national Watershed Boundary Dataset. A feature spanning subwatersheds carries no key rather than a misleading one. |39+| `atlas.stroke.width_px` | collection | positive decimal (world px) | experimental | The ground width of a path collection's features: a trail is a line and a weight, and the weight lets a reader draw the path as one continuous stroke. |40+| `atlas.collection.key` | collection | slug | experimental | A collection's merge identity across sources. Absent, the icon key stands in. |3541 3642 ## Policy names (never written to a payload)3743
M internal/semconv/semconv.go +63 -36
@@ -28,18 +28,20 @@ import (
2828 2929 // Version names the vocabulary a bundle was written against. It rides the3030 // manifest as "conventions" and moves only when an existing key's meaning31-// breaks -- additions ride on per-key stability instead.32-const Version = 131+// breaks -- additions ride on per-key stability instead. v2 collapsed the32+// zone/category/location entities into collection/feature when the format33+// unified them: every kind of thing on a map is a feature, every named set34+// of them a collection.35+const Version = 23336 3437 // Entity is what an attribute attaches to.3538 type Entity string3639 3740 const (38- EntityBundle Entity = "bundle"39- EntityWorld Entity = "world"40- EntityZone Entity = "zone"41- EntityCategory Entity = "category"42- EntityLocation Entity = "location"41+ EntityBundle Entity = "bundle"42+ EntityWorld Entity = "world"43+ EntityCollection Entity = "collection"44+ EntityFeature Entity = "feature"4345 )4446 4547 // Stability says how settled a key is. Experimental keys may still change
@@ -53,18 +55,31 @@ const (
5355 5456 // The registered keys.5557 const (56- // RenderAs says how a category's locations are drawn: as markers or as57- // floating text labels. It replaces the pipeline's single unspoken58+ // GeometryKind says what shape of thing a collection holds: points,59+ // paths, or areas. Every collection declares one kind and every feature60+ // in it is that kind; readers pick their rendering and UX by this key61+ // instead of sniffing geometry types at draw time.62+ KeyGeometryKind = "atlas.geometry.kind"63+64+ // LabelPolicy says whether an area collection's features wear their65+ // names on the map always, or quietly -- only on highlight, selection,66+ // or an explicit reveal. It attaches to area collections only: absent67+ // means always for areas, which is today's look, and paths are always68+ // quiet.69+ KeyLabelPolicy = "atlas.label.policy"70+71+ // RenderAs says how a point collection's features are drawn: as markers72+ // or as floating text labels. It replaces the pipeline's single unspoken5873 // display rule, the string compare on MapGenie's display_type.5974 KeyRenderAs = "atlas.render.as"6075 61- // IconStd names a standard-library icon for a category that has no76+ // IconStd names a standard-library icon for a collection that has no6277 // artwork of its own, as set/name, e.g. "maki/mountain". The ingestion6378 // pipeline resolves it to embedded bytes; the app only ever sees the6479 // resolved asset.6580 KeyIconStd = "atlas.icon.std"6681 67- // IconKind says whether a category's icon asset is a monochrome glyph82+ // IconKind says whether a collection's icon asset is a monochrome glyph6883 // the viewer tints, or a picture drawn as-is. It names what used to be6984 // inferred from the asset's file extension.7085 KeyIconKind = "atlas.icon.kind"
@@ -109,7 +124,7 @@ const (
109124 // decimal string.110125 KeyGeometryRadiusKM = "atlas.geometry.radius_km"111126 112- // GeoLat and GeoLon carry a location's true planetary coordinates as127+ // GeoLat and GeoLon carry a feature's true planetary coordinates as113128 // the source published them: planetocentric degrees, east-positive114129 // longitude. They are provenance and card material; rendering derives115130 // positions from the map-level mapping instead.
@@ -117,22 +132,23 @@ const (
117132 KeyGeoLon = "atlas.geo.lon"118133 119134 // HydroHUC12 names the USGS twelve-digit hydrologic unit -- the120- // subwatershed -- a zone's ground lies wholly within, from the national121- // Watershed Boundary Dataset, so watershed membership is machine-readable122- // beside whatever sentence the zone's card shows. A zone spanning123- // subwatersheds carries no key rather than a misleading one.135+ // subwatershed -- a feature's ground lies wholly within, from the136+ // national Watershed Boundary Dataset, so watershed membership is137+ // machine-readable beside whatever sentence the feature's card shows. A138+ // feature spanning subwatersheds carries no key rather than a misleading139+ // one.124140 KeyHydroHUC12 = "atlas.hydro.huc12"125141 126- // StrokeWidthPx is the ground width of a zone whose features are lines127- // rather than areas, in world pixels: a trail is a line and a weight,128- // and declaring the weight lets a reader draw the path as one129- // continuous stroke instead of an area faked around it.142+ // StrokeWidthPx is the ground width of a path collection's features, in143+ // world pixels: a trail is a line and a weight, and declaring the weight144+ // lets a reader draw the path as one continuous stroke instead of an145+ // area faked around it.130146 KeyStrokeWidthPx = "atlas.stroke.width_px"131147 132- // CategoryKey is a category's merge identity: the slug categories from133- // different sources meet under when they mean the same concept. Absent,134- // the icon key stands in, which is today's behavior named.135- KeyCategoryKey = "atlas.category.key"148+ // CollectionKey is a collection's merge identity: the slug collections149+ // from different sources meet under when they mean the same concept.150+ // Absent, the icon key stands in, which is today's behavior named.151+ KeyCollectionKey = "atlas.collection.key"136152 137153 // NoteText never appears in a payload: it is the name the merge policy138154 // table and ledger use for a pin's description, so "which description
@@ -143,6 +159,13 @@ const (
143159 144160 // Vocabulary values.145161 const (162+ GeometryPoint = "point"163+ GeometryPath = "path"164+ GeometryArea = "area"165+166+ LabelAlways = "always"167+ LabelQuiet = "quiet"168+146169 RenderAsPin = "pin"147170 RenderAsText = "text"148171
@@ -244,9 +267,11 @@ func numbers(count int) func(string) error {
244267 // registry is the whole vocabulary. REGISTRY.md is its prose twin, and a245268 // test holds the two to the same list of keys.246269 var registry = map[string]definition{247- KeyRenderAs: {EntityCategory, Stable, enum(RenderAsPin, RenderAsText)},248- KeyIconStd: {EntityCategory, Stable, setName},249- KeyIconKind: {EntityCategory, Stable, enum(IconKindGlyph, IconKindPicture)},270+ KeyGeometryKind: {EntityCollection, Stable, enum(GeometryPoint, GeometryPath, GeometryArea)},271+ KeyLabelPolicy: {EntityCollection, Experimental, enum(LabelAlways, LabelQuiet)},272+ KeyRenderAs: {EntityCollection, Stable, enum(RenderAsPin, RenderAsText)},273+ KeyIconStd: {EntityCollection, Stable, setName},274+ KeyIconKind: {EntityCollection, Stable, enum(IconKindGlyph, IconKindPicture)},250275 KeyIconOutset: {EntityWorld, Stable, enum(OutsetLight, OutsetDark)},251276 KeyGeometrySurface: {EntityWorld, Stable, enum(SurfacePlane, SurfaceSphere)},252277 KeyGeometryProjection: {EntityWorld, Stable, enum(ProjectionEquirect)},
@@ -256,11 +281,11 @@ var registry = map[string]definition{
256281 KeyGeometryMercatorDeg: {EntityWorld, Experimental, numbers(4)},257282 KeyGeometryBody: {EntityWorld, Experimental, slug},258283 KeyGeometryRadiusKM: {EntityWorld, Experimental, decimal},259- KeyGeoLat: {EntityLocation, Experimental, decimal},260- KeyGeoLon: {EntityLocation, Experimental, decimal},261- KeyHydroHUC12: {EntityZone, Experimental, huc12},262- KeyStrokeWidthPx: {EntityZone, Experimental, positiveDecimal},263- KeyCategoryKey: {EntityCategory, Experimental, slug},284+ KeyGeoLat: {EntityFeature, Experimental, decimal},285+ KeyGeoLon: {EntityFeature, Experimental, decimal},286+ KeyHydroHUC12: {EntityFeature, Experimental, huc12},287+ KeyStrokeWidthPx: {EntityCollection, Experimental, positiveDecimal},288+ KeyCollectionKey: {EntityCollection, Experimental, slug},264289 }265290 266291 // Keys lists every registered key in a stable order, for the tests and the
@@ -310,10 +335,12 @@ func Validate(entity Entity, attrs map[string]string) error {
310335 return nil311336 }312337 313-// RenderAs answers how a category draws, from its attributes first and the314-// legacy display_type field when no attribute speaks. This is the one rule315-// the viewer used to hold as a string compare, spelled once for every316-// consumer: text is text, and everything else is a pin.338+// RenderAs answers how a point collection draws, from its attributes first339+// and the legacy display_type field when no attribute speaks. This is the340+// one rule the viewer used to hold as a string compare, spelled once for341+// every consumer: text is text, and everything else is a pin. The legacy342+// parameter stays until flag day -- the generator still speaks for343+// MapGenie's displayType on the v2 wire.317344 func RenderAs(attrs map[string]string, legacyDisplayType string) string {318345 if value, declared := attrs[KeyRenderAs]; declared {319346 return value
M internal/semconv/semconv_test.go +21 -12
@@ -13,9 +13,12 @@ func TestValidateHoldsTheVocabulary(t *testing.T) {
1313 entity Entity1414 attrs map[string]string1515 }{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"}},16+ {EntityCollection, map[string]string{KeyRenderAs: "pin"}},17+ {EntityCollection, map[string]string{KeyRenderAs: "text", KeyIconStd: "maki/mountain"}},18+ {EntityCollection, map[string]string{KeyIconKind: "glyph", KeyCollectionKey: "ripperdoc"}},19+ {EntityCollection, map[string]string{KeyGeometryKind: "point"}},20+ {EntityCollection, map[string]string{KeyGeometryKind: "path", KeyStrokeWidthPx: "12"}},21+ {EntityCollection, map[string]string{KeyGeometryKind: "area", KeyLabelPolicy: "quiet"}},1922 {EntityWorld, map[string]string{2023 KeyGeometrySurface: "sphere",2124 KeyGeometryProjection: "equirect",
@@ -25,9 +28,10 @@ func TestValidateHoldsTheVocabulary(t *testing.T) {
2528 KeyGeometryRadiusKM: "3389.5",2629 }},2730 {EntityWorld, map[string]string{KeyIconOutset: "dark"}},28- {EntityLocation, map[string]string{KeyGeoLat: "-42.4301", KeyGeoLon: "70.5025"}},31+ {EntityFeature, map[string]string{KeyGeoLat: "-42.4301", KeyGeoLon: "70.5025"}},32+ {EntityFeature, map[string]string{KeyHydroHUC12: "170703010101"}},2933 // Empty is always fine: conventions are declared, never demanded.30- {EntityCategory, nil},34+ {EntityCollection, nil},3135 }3236 for _, test := range good {3337 if err := Validate(test.entity, test.attrs); err != nil {
@@ -40,16 +44,21 @@ func TestValidateHoldsTheVocabulary(t *testing.T) {
4044 attrs map[string]string4145 mention string4246 }{43- {EntityCategory, map[string]string{"atlas.render.like": "pin"}, "not registered"},44- {EntityCategory, map[string]string{KeyRenderAs: "zone"}, "not one of"},47+ {EntityCollection, map[string]string{"atlas.render.like": "pin"}, "not registered"},48+ {EntityCollection, map[string]string{KeyRenderAs: "zone"}, "not one of"},4549 {EntityWorld, 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"},50+ {EntityCollection, map[string]string{KeyIconStd: "mountain"}, "set/name"},51+ {EntityCollection, map[string]string{KeyIconStd: "Maki/Mountain"}, "slug"},52+ {EntityCollection, map[string]string{KeyGeometryKind: "polygon"}, "not one of"},53+ {EntityCollection, map[string]string{KeyLabelPolicy: "loud"}, "not one of"},54+ {EntityFeature, map[string]string{KeyGeometryKind: "area"}, "attaches to"},55+ {EntityFeature, map[string]string{KeyStrokeWidthPx: "12"}, "attaches to"},4856 {EntityWorld, map[string]string{KeyGeometryEquirectPx: "0,0,8192"}, "4"},4957 {EntityWorld, map[string]string{KeyGeometryRadiusKM: "big"}, "number"},50- {EntityLocation, map[string]string{KeyGeoLat: "north"}, "number"},58+ {EntityFeature, map[string]string{KeyGeoLat: "north"}, "number"},59+ {EntityCollection, map[string]string{KeyHydroHUC12: "170703010101"}, "attaches to"},5160 // The policy name never rides a payload.52- {EntityLocation, map[string]string{KeyNoteText: "words"}, "not registered"},61+ {EntityFeature, map[string]string{KeyNoteText: "words"}, "not registered"},5362 }5463 for _, test := range bad {5564 err := Validate(test.entity, test.attrs)
@@ -131,7 +140,7 @@ func TestRegistryAgreesWithItsDocument(t *testing.T) {
131140 if err != nil {132141 t.Fatal(err)133142 }134- row := regexp.MustCompile("(?m)^\\| `(atlas\\.[a-z0-9_.]+)` \\| (bundle|world|zone|category|location) \\|.*\\| (stable|experimental) \\|")143+ row := regexp.MustCompile("(?m)^\\| `(atlas\\.[a-z0-9_.]+)` \\| (bundle|world|collection|feature) \\|.*\\| (stable|experimental) \\|")135144 documented := make(map[string][2]string)136145 for _, match := range row.FindAllStringSubmatch(string(doc), -1) {137146 documented[match[1]] = [2]string{match[2], match[3]}
M tools/generate/bundles.go +8 -4
M tools/generate/main.go +28 -5
@@ -525,7 +525,7 @@ func speakConventions(game *catalogVolume) error {
525525 collection := &m.Collections[collectionIndex]526526 if collection.Kind != kindPoint {527527 for _, shape := range collection.Features {528- if err := semconv.Validate(semconv.EntityZone, shape.Attrs); err != nil {528+ if err := validateShapeAttrs(shape.Attrs); err != nil {529529 return fmt.Errorf("world %s zone %q: %w", m.Slug, shape.Title, err)530530 }531531 }
@@ -540,8 +540,8 @@ func speakConventions(game *catalogVolume) error {
540540 // reads identity off the payload rather than a table of its541541 // own.542542 if shared := categoryEquivalents[game.Slug][collection.Icon]; shared != "" {543- if _, declared := collection.Attrs[semconv.KeyCategoryKey]; !declared {544- collection.Attrs = withAttr(collection.Attrs, semconv.KeyCategoryKey, shared)543+ if _, declared := collection.Attrs[semconv.KeyCollectionKey]; !declared {544+ collection.Attrs = withAttr(collection.Attrs, semconv.KeyCollectionKey, shared)545545 }546546 }547547 if collection.IconAsset != "" {
@@ -551,11 +551,11 @@ func speakConventions(game *catalogVolume) error {
551551 }552552 collection.Attrs = withAttr(collection.Attrs, semconv.KeyIconKind, kind)553553 }554- if err := semconv.Validate(semconv.EntityCategory, collection.Attrs); err != nil {554+ if err := semconv.Validate(semconv.EntityCollection, collection.Attrs); err != nil {555555 return fmt.Errorf("world %s category %q: %w", m.Slug, collection.Title, err)556556 }557557 for _, pin := range collection.Features {558- if err := semconv.Validate(semconv.EntityLocation, pin.Attrs); err != nil {558+ if err := semconv.Validate(semconv.EntityFeature, pin.Attrs); err != nil {559559 return fmt.Errorf("world %s pin %q: %w", m.Slug, pin.Title, err)560560 }561561 }
@@ -564,6 +564,29 @@ func speakConventions(game *catalogVolume) error {
564564 return nil565565 }566566 567+// validateShapeAttrs holds a shape feature's attributes to the registry as568+// the feature they attach to -- with one allowance that lasts until the569+// unified wire: the v2 wire spells a path collection's stroke width on each570+// zone, so the zone answers for its collection's one attribute here. Flag571+// day moves the write where the registry says the key lives and this572+// allowance goes with it.573+func validateShapeAttrs(attrs map[string]string) error {574+ if width, carried := attrs[semconv.KeyStrokeWidthPx]; carried {575+ if err := semconv.Validate(semconv.EntityCollection,576+ map[string]string{semconv.KeyStrokeWidthPx: width}); err != nil {577+ return err578+ }579+ rest := make(map[string]string, len(attrs)-1)580+ for key, value := range attrs {581+ if key != semconv.KeyStrokeWidthPx {582+ rest[key] = value583+ }584+ }585+ attrs = rest586+ }587+ return semconv.Validate(semconv.EntityFeature, attrs)588+}589+567590 // withAttr sets one attribute on a copy of the map, never the map itself: a568591 // split sheet's pieces share their source's attributes by reference, and569592 // speaking for one piece must not put words in another's mouth.
M tools/generate/merge.go +5 -5
@@ -38,7 +38,7 @@ const (
3838 3939 // categoryEquivalents names the shared concept where two sources spell one4040 // category differently, per game. It is applied by speakConventions, which41-// writes the shared name onto each category as atlas.category.key -- so the41+// writes the shared name onto each category as atlas.collection.key -- so the4242 // payloads themselves carry the merge identity, and the merge below reads4343 // only the attribute. Slugs equal after normalization pair automatically;4444 // everything else stays source-specific rather than being guessed together.
@@ -58,7 +58,7 @@ var categoryEquivalents = map[string]map[string]string{
5858 // the payload carries one, its icon key otherwise -- which is today's5959 // behavior, named.6060 func categoryKey(icon string, attrs map[string]string) string {61- if key := attrs[semconv.KeyCategoryKey]; key != "" {61+ if key := attrs[semconv.KeyCollectionKey]; key != "" {6262 return key6363 }6464 return icon
@@ -379,7 +379,7 @@ func mergeWorld(
379379 // the serving map does not have at all keep their donor categories, so380380 // the source-named group holds nothing but what is truly source-specific.381381 // Categories meet under their declared merge identity -- the382- // atlas.category.key their payloads carry, their icon key otherwise --382+ // atlas.collection.key their payloads carry, their icon key otherwise --383383 // so the equivalence curation lives in the payloads, not here.384384 var keptCollections []worldCollection385385 for _, donorCollection := range donor.Collections {
@@ -847,7 +847,7 @@ func mergeGate(merge *mergedSource, winner *catalogWorld) error {
847847 tookNote = true848848 continue849849 }850- if entity, known := semconv.EntityOf(key); !known || entity != semconv.EntityLocation {850+ if entity, known := semconv.EntityOf(key); !known || entity != semconv.EntityFeature {851851 return fmt.Errorf("pair %d took %q, which no pin may carry", pair.Donor, key)852852 }853853 }
@@ -857,7 +857,7 @@ func mergeGate(merge *mergedSource, winner *catalogWorld) error {
857857 }858858 }859859 for _, take := range merge.Enriched {860- if entity, known := semconv.EntityOf(take.Key); !known || entity != semconv.EntityCategory {860+ if entity, known := semconv.EntityOf(take.Key); !known || entity != semconv.EntityCollection {861861 return fmt.Errorf("category %q took %q, which no category may carry", take.Category, take.Key)862862 }863863 }