clone

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

feat: judge every build with one yardstick

Dami committed aad407a
The workbench and the maturity report each carried their own copy of
the same measurements, and the registry's ordering rule lived in three
places at once, so "how good is this build" could quietly mean
different things in different rooms. internal/measure now defines the
axes once, and both tools become renderers of it.

The yardstick is aligned with the conventions the payloads speak: a
text category is whatever atlas.render.as says it is, the same reading
the viewer makes, so the metric and the behavior can never drift
apart. New axes measure adoption of the vocabulary itself -- how much
of it a build declares, whether it says anything the registry does not
know -- and a build from before the conventions says so plainly rather
than scoring zero on a test it never sat.
commit
aad407a005521053b4da56ad5220f5610c415aa1
parent
3477b5c020e255ab0fd015ce5574c217532ad379
author
Dami <dami.etoile@gmail.com>
5 files changed, +514 -656
Mcmd/cartograph/diff.go7+
Mcmd/cartograph/library.go18+-
Mcmd/cartograph/measure.go377+------------------------
Ainternal/measure/measure.go450++++++++++++++++++++++++++++++
Mtools/maturity/main.go318+++-------------------
M cmd/cartograph/diff.go +7 -0
M cmd/cartograph/library.go +4 -14
M cmd/cartograph/measure.go +13 -364
@@ -1,373 +1,22 @@
11 package main22 3-import (4- "archive/zip"5- "encoding/json"6- "fmt"7- "path/filepath"8- "sort"9- "strconv"10- "strings"3+// The workbench judges a build along the axes internal/measure defines --4+// one yardstick shared with tools/maturity, aligned with the semantic5+// conventions the payloads speak, so "how good is this build" means the6+// same thing in every room. The workbench keeps the ledgers whole where the7+// report prints counts: every matched pair, every held pin with its reason,8+// so a page can lay two builds' accounts beside each other and show exactly9+// what a policy change moved.1110 12- "github.com/FelineStateMachine/atlas/internal/bundle"13-)11+import "github.com/FelineStateMachine/atlas/internal/measure"1412 15-// The workbench judges a build along the same axes tools/maturity prints:16-// how thoroughly its locations are explained in writing, how much visual map17-// information its rasters hold, and how much machine-readable structure it18-// carries beyond the pixels, with the icons its legend draws counted19-// alongside. Every number is an absolute measurement of one build, never a20-// rank within the collection: ranks move when the collection does, and a21-// judgement worth acting on needs figures that stand still.22-//23-// Where the report stopped at counts, the workbench keeps the ledgers whole:24-// every matched pair, every held pin with its reason, every adoption -- so a25-// page can lay two builds' accounts beside each other and show exactly what26-// a policy change moved.13+type build = measure.Build14+type mergeAccount = measure.MergeAccount2715 28-// build is one .atlas file measured whole: its identity, the axes, and every29-// merge account its payloads carry.30-type build struct {31- Path string32- File string33- GameSlug string34- GameTitle string35- Stamp string36- CreatedAt string37- Revision int38- MapSlugs []string16+func measureBundle(path string) (*build, error) { return measure.MeasureBundle(path) }3917 40- Pins int41- Described int42- MedianLength int43- TileCount int44- RasterBytes int6445- Depth int46- DepthTiles int47- Categories int48- Groups int49- TextSets int50- Zones int51- Vertices int52- Variants int53- IconsCarried int54- IconsWanted int55- Merges []mergeAccount56-}57-58-// mergeAccount is one source's account of its merge into one map, kept whole59-// rather than counted: the pairs and the held pins are what a diff compares.60-type mergeAccount struct {61- Map string62- Source string63- DonorPins int64- Matched []matchedPair65- Added int66- Adopted []adoptedPin67- Held []heldPin68- Rejected []heldPin69- Alignment string70-}71-72-// matchedPair records one place both sources pin, in the ledger's own words.73-type matchedPair struct {74- Donor int64 `json:"d"`75- Winner int64 `json:"w"`76- DistancePx int `json:"px"`77- Enriched bool `json:"e"`78-}79-80-// adoptedPin records a donor-only pin that joined one of the serving map's81-// own categories.82-type adoptedPin struct {83- Donor int64 `json:"d"`84- Into string `json:"into"`85-}86-87-// heldPin is a donor pin the merge did not carry, with the reason.88-type heldPin struct {89- Donor int64 `json:"d"`90- Title string `json:"t"`91- Reason string `json:"why"`92-}93-94-// mapDetail is the sliver of a map payload measurement reads.95-type mapDetail struct {96- Groups []struct {97- Categories []struct {98- IconAsset string `json:"iconAsset"`99- DisplayType string `json:"displayType"`100- } `json:"categories"`101- } `json:"groups"`102- Merged []struct {103- Source string `json:"source"`104- DonorPins int `json:"donorPins"`105- Matched []matchedPair `json:"matched"`106- Added int `json:"added"`107- Adopted []adoptedPin `json:"adopted"`108- Held []heldPin `json:"held"`109- Rejected []heldPin `json:"rejected"`110- Alignment string `json:"alignment"`111- } `json:"merged"`112- Zones []struct {113- Features []struct {114- Coordinates json.RawMessage `json:"coordinates"`115- } `json:"features"`116- } `json:"zones"`117- Variants []struct {118- Name string `json:"name"`119- MaxZoom int `json:"maxZoom"`120- } `json:"variants"`121-}122-123-// MedianMatchPx is the middle distance of the account's matched pairs: the124-// figure that says how well the alignment held, unmoved by one outlier.125-func (m mergeAccount) MedianMatchPx() int {126- if len(m.Matched) == 0 {127- return 0128- }129- distances := make([]int, 0, len(m.Matched))130- for _, pair := range m.Matched {131- distances = append(distances, pair.DistancePx)132- }133- sort.Ints(distances)134- return distances[len(distances)/2]135-}136-137-func (m mergeAccount) MatchedN() int { return len(m.Matched) }138-func (m mergeAccount) AdoptedN() int { return len(m.Adopted) }139-func (m mergeAccount) HeldN() int { return len(m.Held) }140-func (m mergeAccount) RejectedN() int { return len(m.Rejected) }141-142-// DescribedPct is the annotation share as pages print it.143-func (b *build) DescribedPct() string { return percent(b.Described, b.Pins) }144-145-// IconPct is the icon coverage as pages print it.146-func (b *build) IconPct() string { return percent(b.IconsCarried, b.IconsWanted) }147-148-// RasterMB is the unique raster weight in megabytes, one decimal.149-func (b *build) RasterMB() string { return fmt.Sprintf("%.1f", float64(b.RasterBytes)/1e6) }150-151-// ShortStamp is the stamp as pages carry it.152-func (b *build) ShortStamp() string { return bundle.ShortStamp(b.Stamp) }153-154-// SourcesSeen lists the distinct sources the build's merge accounts name,155-// sorted. A bundle is source-agnostic by design, so the ledger is the only156-// place a source's name survives into a build.157-func (b *build) SourcesSeen() []string {158- seen := make(map[string]bool)159- var names []string160- for _, account := range b.Merges {161- if !seen[account.Source] {162- seen[account.Source] = true163- names = append(names, account.Source)164- }165- }166- sort.Strings(names)167- return names168-}169-170-func percent(part, whole int) string {171- if whole == 0 {172- return "—"173- }174- return strconv.Itoa(part*100/whole) + "%"175-}176-177-// measureBundle reads one .atlas whole and returns its measurements. It reads178-// the zip directly rather than through bundle.Open because the cartography179-// axis needs what only the archive's table of contents knows: every tile180-// entry's checksum and size, for de-duplicating filler.181-func measureBundle(path string) (*build, error) {182- reader, err := zip.OpenReader(path)183- if err != nil {184- return nil, err185- }186- defer reader.Close()187-188- entries := make(map[string]*zip.File, len(reader.File))189- for _, file := range reader.File {190- entries[file.Name] = file191- }192- var manifest bundle.Manifest193- if err := readEntry(entries, bundle.ManifestName, &manifest); err != nil {194- return nil, err195- }196- if err := manifest.Validate(); err != nil {197- return nil, err198- }199-200- b := &build{201- Path: path,202- File: filepath.Base(path),203- GameSlug: manifest.Game.Slug,204- GameTitle: manifest.Game.Title,205- Stamp: manifest.Version.Stamp,206- CreatedAt: manifest.Version.CreatedAt,207- Revision: manifest.Version.Revision,208- }209-210- // Annotation: how thoroughly the locations are explained in writing.211- var lengths []int212- for _, entry := range manifest.Maps {213- b.MapSlugs = append(b.MapSlugs, entry.Slug)214- b.Pins += entry.PinCount215- var text map[string]struct {216- Description string `json:"d"`217- }218- if err := readEntry(entries, "maps/"+entry.Slug+".text", &text); err != nil {219- return nil, err220- }221- for _, held := range text {222- cleaned := strings.Join(strings.Fields(held.Description), " ")223- if cleaned == "" {224- continue225- }226- b.Described++227- lengths = append(lengths, len(cleaned))228- }229-230- var detail mapDetail231- if err := readEntry(entries, "maps/"+entry.Slug+".json", &detail); err != nil {232- return nil, err233- }234- b.Variants += len(detail.Variants)235- for _, variant := range detail.Variants {236- b.Depth = max(b.Depth, variant.MaxZoom)237- }238- b.Zones += len(detail.Zones)239- for _, zone := range detail.Zones {240- for _, feature := range zone.Features {241- b.Vertices += countVertices(feature.Coordinates)242- }243- }244- for _, group := range detail.Groups {245- b.Groups++246- for _, category := range group.Categories {247- b.Categories++248- if category.DisplayType == "text" {249- b.TextSets++250- continue251- }252- b.IconsWanted++253- if category.IconAsset != "" {254- b.IconsCarried++255- }256- }257- }258- for _, merged := range detail.Merged {259- b.Merges = append(b.Merges, mergeAccount{260- Map: entry.Slug,261- Source: merged.Source,262- DonorPins: merged.DonorPins,263- Matched: merged.Matched,264- Added: merged.Added,265- Adopted: merged.Adopted,266- Held: merged.Held,267- Rejected: merged.Rejected,268- Alignment: merged.Alignment,269- })270- }271- }272- sort.Ints(lengths)273- if len(lengths) > 0 {274- b.MedianLength = lengths[len(lengths)/2]275- }276-277- // Cartography: the stored representation, de-duplicated -- a border of278- // repeated filler tiles is one tile's worth of information however many279- // times it appears.280- seen := make(map[[2]uint64]bool)281- for name, file := range entries {282- if !strings.HasPrefix(name, "tiles/") {283- continue284- }285- b.TileCount++286- key := [2]uint64{uint64(file.CRC32), file.UncompressedSize64}287- if !seen[key] {288- seen[key] = true289- b.RasterBytes += int64(file.UncompressedSize64)290- }291- if zoom := tileZoom(name); zoom == b.Depth {292- b.DepthTiles++293- }294- }295- return b, nil296-}297-298-// loadPins reads the packed locations of every map, keyed the way a diff299-// compares them: by map slug, then pin id.30018 func loadPins(path string, mapSlugs []string) (map[string]map[int64]string, error) {301- opened, err := bundle.Open(path)302- if err != nil {303- return nil, err304- }305- defer opened.Close()306-307- pins := make(map[string]map[int64]string, len(mapSlugs))308- for _, slug := range mapSlugs {309- packed, err := opened.ReadEntry("maps/" + slug + ".bin")310- if err != nil {311- return nil, fmt.Errorf("map %s: %w", slug, err)312- }313- locations, err := bundle.UnpackLocations(packed)314- if err != nil {315- return nil, fmt.Errorf("map %s: %w", slug, err)316- }317- titles := make(map[int64]string, len(locations))318- for _, location := range locations {319- titles[location.ID] = location.Title320- }321- pins[slug] = titles322- }323- return pins, nil19+ return measure.LoadPins(path, mapSlugs)32420 }32521 326-func readEntry(entries map[string]*zip.File, name string, into any) error {327- file, ok := entries[name]328- if !ok {329- return fmt.Errorf("%s is missing", name)330- }331- opened, err := file.Open()332- if err != nil {333- return err334- }335- defer opened.Close()336- return json.NewDecoder(opened).Decode(into)337-}338-339-// tileZoom reads the level out of tiles/<pyramid>/<zoom>/<x>/<y>.<ext>.340-func tileZoom(name string) int {341- parts := strings.Split(name, "/")342- if len(parts) < 5 {343- return -1344- }345- zoom, err := strconv.Atoi(parts[len(parts)-3])346- if err != nil {347- return -1348- }349- return zoom350-}351-352-// countVertices counts coordinate pairs through however many rings and353-// nestings a geometry holds.354-func countVertices(raw json.RawMessage) int {355- var value any356- if err := json.Unmarshal(raw, &value); err != nil {357- return 0358- }359- numbers := 0360- var walk func(any)361- walk = func(node any) {362- switch typed := node.(type) {363- case []any:364- for _, child := range typed {365- walk(child)366- }367- case float64:368- numbers++369- }370- }371- walk(value)372- return numbers / 2373-}22+func percent(part, whole int) string { return measure.Percent(part, whole) }
A internal/measure/measure.go +450 -0
@@ -0,0 +1,450 @@
1+// Package measure is the one place a build of a game is judged. The2+// cartograph workbench and the maturity report used to each carry their own3+// copy of the same yardstick -- and the registry's ordering rule lived in4+// three places at once -- so "how good is this build" could quietly mean5+// different things in different rooms. Now the axes are defined once,6+// against the same semantic conventions the payloads speak: the metrics7+// measure adoption of the vocabulary, and the vocabulary defines what the8+// metrics mean.9+//10+// Every number is an absolute measurement of one build, never a rank within11+// the collection: ranks move when the collection does, and a judgement worth12+// acting on needs figures that stand still.13+package measure14+15+import (16+ "archive/zip"17+ "encoding/json"18+ "fmt"19+ "path/filepath"20+ "sort"21+ "strconv"22+ "strings"23+24+ "github.com/FelineStateMachine/atlas/internal/bundle"25+ "github.com/FelineStateMachine/atlas/internal/semconv"26+)27+28+// Build is one .atlas file measured whole: its identity, the axes, and every29+// merge account its payloads carry.30+type Build struct {31+ Path string32+ File string33+ GameSlug string34+ GameTitle string35+ Stamp string36+ CreatedAt string37+ Revision int38+ MapSlugs []string39+40+ Pins int41+ Described int42+ MedianLength int43+ TileCount int44+ RasterBytes int6445+ Depth int46+ DepthTiles int47+ Categories int48+ Groups int49+ TextSets int50+ Zones int51+ Vertices int52+ Variants int53+ IconsCarried int54+ IconsWanted int55+56+ // The conventions axes: which vocabulary the bundle declares, how much57+ // of it the payloads actually speak, and whether they say anything the58+ // registry does not know -- which a valid bundle never does, so a59+ // non-zero count is itself a finding.60+ Conventions int61+ RenderDeclared int62+ StdIcons int63+ GeoPins int64+ Geometry string65+ UnknownAttrs int66+67+ Merges []MergeAccount68+}69+70+// MergeAccount is one source's account of its merge into one map, kept whole71+// rather than counted: the pairs and the held pins are what a diff compares.72+type MergeAccount struct {73+ Map string74+ Source string75+ DonorPins int76+ Matched []MatchedPair77+ Added int78+ Adopted []AdoptedPin79+ Held []HeldPin80+ Rejected []HeldPin81+ Alignment string82+}83+84+// MatchedPair records one place both sources pin, in the ledger's own words.85+type MatchedPair struct {86+ Donor int64 `json:"d"`87+ Winner int64 `json:"w"`88+ DistancePx int `json:"px"`89+ Enriched bool `json:"e"`90+}91+92+// AdoptedPin records a donor-only pin that joined one of the serving map's93+// own categories.94+type AdoptedPin struct {95+ Donor int64 `json:"d"`96+ Into string `json:"into"`97+}98+99+// HeldPin is a donor pin the merge did not carry, with the reason.100+type HeldPin struct {101+ Donor int64 `json:"d"`102+ Title string `json:"t"`103+ Reason string `json:"why"`104+}105+106+// mapDetail is the sliver of a map payload measurement reads.107+type mapDetail struct {108+ Attrs map[string]string `json:"attrs"`109+ Groups []struct {110+ Categories []struct {111+ IconAsset string `json:"iconAsset"`112+ DisplayType string `json:"displayType"`113+ Attrs map[string]string `json:"attrs"`114+ } `json:"categories"`115+ } `json:"groups"`116+ Merged []struct {117+ Source string `json:"source"`118+ DonorPins int `json:"donorPins"`119+ Matched []MatchedPair `json:"matched"`120+ Added int `json:"added"`121+ Adopted []AdoptedPin `json:"adopted"`122+ Held []HeldPin `json:"held"`123+ Rejected []HeldPin `json:"rejected"`124+ Alignment string `json:"alignment"`125+ } `json:"merged"`126+ Zones []struct {127+ Features []struct {128+ Coordinates json.RawMessage `json:"coordinates"`129+ } `json:"features"`130+ } `json:"zones"`131+ Variants []struct {132+ Name string `json:"name"`133+ MaxZoom int `json:"maxZoom"`134+ } `json:"variants"`135+}136+137+// MedianMatchPx is the middle distance of the account's matched pairs: the138+// figure that says how well the alignment held, unmoved by one outlier.139+func (m MergeAccount) MedianMatchPx() int {140+ if len(m.Matched) == 0 {141+ return 0142+ }143+ distances := make([]int, 0, len(m.Matched))144+ for _, pair := range m.Matched {145+ distances = append(distances, pair.DistancePx)146+ }147+ sort.Ints(distances)148+ return distances[len(distances)/2]149+}150+151+func (m MergeAccount) MatchedN() int { return len(m.Matched) }152+func (m MergeAccount) AdoptedN() int { return len(m.Adopted) }153+func (m MergeAccount) HeldN() int { return len(m.Held) }154+func (m MergeAccount) RejectedN() int { return len(m.Rejected) }155+156+// DescribedPct is the annotation share as pages print it.157+func (b *Build) DescribedPct() string { return Percent(b.Described, b.Pins) }158+159+// IconPct is the icon coverage as pages print it.160+func (b *Build) IconPct() string { return Percent(b.IconsCarried, b.IconsWanted) }161+162+// RenderPct is the share of categories that declare how they render.163+func (b *Build) RenderPct() string { return Percent(b.RenderDeclared, b.Categories) }164+165+// RasterMB is the unique raster weight in megabytes, one decimal.166+func (b *Build) RasterMB() string { return fmt.Sprintf("%.1f", float64(b.RasterBytes)/1e6) }167+168+// ShortStamp is the stamp as pages carry it.169+func (b *Build) ShortStamp() string { return bundle.ShortStamp(b.Stamp) }170+171+// SourcesSeen lists the distinct sources the build's merge accounts name,172+// sorted. A bundle is source-agnostic by design, so the ledger is the only173+// place a source's name survives into a build.174+func (b *Build) SourcesSeen() []string {175+ seen := make(map[string]bool)176+ var names []string177+ for _, account := range b.Merges {178+ if !seen[account.Source] {179+ seen[account.Source] = true180+ names = append(names, account.Source)181+ }182+ }183+ sort.Strings(names)184+ return names185+}186+187+// Newer reports whether a should stand before b: the registry's own fold,188+// bundle.MoreRecent, restated over measurements so every report lists builds189+// in the order the reader would actually serve them. This is the one copy190+// outside the registry itself.191+func Newer(a, b *Build) bool {192+ if a.CreatedAt != b.CreatedAt {193+ return a.CreatedAt > b.CreatedAt194+ }195+ if a.Revision != b.Revision {196+ return a.Revision > b.Revision197+ }198+ if a.Stamp != b.Stamp {199+ return a.Stamp > b.Stamp200+ }201+ return a.Path > b.Path202+}203+204+// Percent is the share as every page and report prints it, with an em dash205+// where there is nothing to take a share of.206+func Percent(part, whole int) string {207+ if whole == 0 {208+ return "—"209+ }210+ return strconv.Itoa(part*100/whole) + "%"211+}212+213+// MeasureBundle reads one .atlas whole and returns its measurements. It214+// reads the zip directly rather than through bundle.Open because the215+// cartography axis needs what only the archive's table of contents knows:216+// every tile entry's checksum and size, for de-duplicating filler.217+func MeasureBundle(path string) (*Build, error) {218+ reader, err := zip.OpenReader(path)219+ if err != nil {220+ return nil, err221+ }222+ defer reader.Close()223+224+ entries := make(map[string]*zip.File, len(reader.File))225+ for _, file := range reader.File {226+ entries[file.Name] = file227+ }228+ var manifest bundle.Manifest229+ if err := readEntry(entries, bundle.ManifestName, &manifest); err != nil {230+ return nil, err231+ }232+ if err := manifest.Validate(); err != nil {233+ return nil, err234+ }235+236+ b := &Build{237+ Path: path,238+ File: filepath.Base(path),239+ GameSlug: manifest.Game.Slug,240+ GameTitle: manifest.Game.Title,241+ Stamp: manifest.Version.Stamp,242+ CreatedAt: manifest.Version.CreatedAt,243+ Revision: manifest.Version.Revision,244+ Conventions: manifest.Conventions,245+ Geometry: "plane-default",246+ }247+248+ // Annotation: how thoroughly the locations are explained in writing.249+ var lengths []int250+ for _, entry := range manifest.Maps {251+ b.MapSlugs = append(b.MapSlugs, entry.Slug)252+ b.Pins += entry.PinCount253+ var text map[string]struct {254+ Description string `json:"d"`255+ Attrs map[string]string `json:"a"`256+ }257+ if err := readEntry(entries, "maps/"+entry.Slug+".text", &text); err != nil {258+ return nil, err259+ }260+ for _, held := range text {261+ if held.Attrs[semconv.KeyGeoLat] != "" && held.Attrs[semconv.KeyGeoLon] != "" {262+ b.GeoPins++263+ }264+ b.UnknownAttrs += unknownAttrs(held.Attrs)265+ cleaned := strings.Join(strings.Fields(held.Description), " ")266+ if cleaned == "" {267+ continue268+ }269+ b.Described++270+ lengths = append(lengths, len(cleaned))271+ }272+273+ var detail mapDetail274+ if err := readEntry(entries, "maps/"+entry.Slug+".json", &detail); err != nil {275+ return nil, err276+ }277+ b.UnknownAttrs += unknownAttrs(detail.Attrs)278+ switch detail.Attrs[semconv.KeyGeometrySurface] {279+ case semconv.SurfaceSphere:280+ b.Geometry = semconv.SurfaceSphere281+ case semconv.SurfacePlane:282+ if b.Geometry != semconv.SurfaceSphere {283+ b.Geometry = semconv.SurfacePlane284+ }285+ }286+ b.Variants += len(detail.Variants)287+ for _, variant := range detail.Variants {288+ b.Depth = max(b.Depth, variant.MaxZoom)289+ }290+ b.Zones += len(detail.Zones)291+ for _, zone := range detail.Zones {292+ for _, feature := range zone.Features {293+ b.Vertices += countVertices(feature.Coordinates)294+ }295+ }296+ for _, group := range detail.Groups {297+ b.Groups++298+ for _, category := range group.Categories {299+ b.Categories++300+ b.UnknownAttrs += unknownAttrs(category.Attrs)301+ if _, declared := category.Attrs[semconv.KeyRenderAs]; declared {302+ b.RenderDeclared++303+ }304+ if category.Attrs[semconv.KeyIconStd] != "" {305+ b.StdIcons++306+ }307+ // The conventions decide what a category is, so the metric308+ // judging it reads the same attribute the viewer does.309+ if semconv.RenderAs(category.Attrs, category.DisplayType) == semconv.RenderAsText {310+ b.TextSets++311+ continue312+ }313+ b.IconsWanted++314+ if category.IconAsset != "" {315+ b.IconsCarried++316+ }317+ }318+ }319+ for _, merged := range detail.Merged {320+ b.Merges = append(b.Merges, MergeAccount{321+ Map: entry.Slug,322+ Source: merged.Source,323+ DonorPins: merged.DonorPins,324+ Matched: merged.Matched,325+ Added: merged.Added,326+ Adopted: merged.Adopted,327+ Held: merged.Held,328+ Rejected: merged.Rejected,329+ Alignment: merged.Alignment,330+ })331+ }332+ }333+ sort.Ints(lengths)334+ if len(lengths) > 0 {335+ b.MedianLength = lengths[len(lengths)/2]336+ }337+338+ // Cartography: the stored representation, de-duplicated -- a border of339+ // repeated filler tiles is one tile's worth of information however many340+ // times it appears.341+ seen := make(map[[2]uint64]bool)342+ for name, file := range entries {343+ if !strings.HasPrefix(name, "tiles/") {344+ continue345+ }346+ b.TileCount++347+ key := [2]uint64{uint64(file.CRC32), file.UncompressedSize64}348+ if !seen[key] {349+ seen[key] = true350+ b.RasterBytes += int64(file.UncompressedSize64)351+ }352+ if zoom := tileZoom(name); zoom == b.Depth {353+ b.DepthTiles++354+ }355+ }356+ return b, nil357+}358+359+// unknownAttrs counts the atlas-namespace keys the registry does not know.360+// Validation refuses them at the producer, so a count above zero means a361+// bundle written by a newer vocabulary than this build of the tools.362+func unknownAttrs(attrs map[string]string) int {363+ unknown := 0364+ for key := range attrs {365+ if !strings.HasPrefix(key, "atlas.") {366+ continue367+ }368+ if _, known := semconv.EntityOf(key); !known {369+ unknown++370+ }371+ }372+ return unknown373+}374+375+// LoadPins reads the packed locations of every map, keyed the way a diff376+// compares them: by map slug, then pin id.377+func LoadPins(path string, mapSlugs []string) (map[string]map[int64]string, error) {378+ opened, err := bundle.Open(path)379+ if err != nil {380+ return nil, err381+ }382+ defer opened.Close()383+384+ pins := make(map[string]map[int64]string, len(mapSlugs))385+ for _, slug := range mapSlugs {386+ packed, err := opened.ReadEntry("maps/" + slug + ".bin")387+ if err != nil {388+ return nil, fmt.Errorf("map %s: %w", slug, err)389+ }390+ locations, err := bundle.UnpackLocations(packed)391+ if err != nil {392+ return nil, fmt.Errorf("map %s: %w", slug, err)393+ }394+ titles := make(map[int64]string, len(locations))395+ for _, location := range locations {396+ titles[location.ID] = location.Title397+ }398+ pins[slug] = titles399+ }400+ return pins, nil401+}402+403+func readEntry(entries map[string]*zip.File, name string, into any) error {404+ file, ok := entries[name]405+ if !ok {406+ return fmt.Errorf("%s is missing", name)407+ }408+ opened, err := file.Open()409+ if err != nil {410+ return err411+ }412+ defer opened.Close()413+ return json.NewDecoder(opened).Decode(into)414+}415+416+// tileZoom reads the level out of tiles/<pyramid>/<zoom>/<x>/<y>.<ext>.417+func tileZoom(name string) int {418+ parts := strings.Split(name, "/")419+ if len(parts) < 5 {420+ return -1421+ }422+ zoom, err := strconv.Atoi(parts[len(parts)-3])423+ if err != nil {424+ return -1425+ }426+ return zoom427+}428+429+// countVertices counts coordinate pairs through however many rings and430+// nestings a geometry holds.431+func countVertices(raw json.RawMessage) int {432+ var value any433+ if err := json.Unmarshal(raw, &value); err != nil {434+ return 0435+ }436+ numbers := 0437+ var walk func(any)438+ walk = func(node any) {439+ switch typed := node.(type) {440+ case []any:441+ for _, child := range typed {442+ walk(child)443+ }444+ case float64:445+ numbers++446+ }447+ }448+ walk(value)449+ return numbers / 2450+}
M tools/maturity/main.go +40 -278
@@ -1,9 +1,10 @@
11 // Command maturity reads the library and reports how mature each build of2-// each game is, along the three axes that judge a game's evolution as its3-// sources mix: how thoroughly its locations are explained in writing, how4-// much visual map information its rasters hold, and how much machine-readable5-// structure it carries beyond the pixels -- with the icons its legend draws6-// counted alongside.2+// each game is, along the axes internal/measure defines -- the same3+// yardstick the cartograph workbench judges by, aligned with the semantic4+// conventions the payloads speak: how thoroughly locations are explained in5+// writing, how much visual map information the rasters hold, how much6+// machine-readable structure rides beyond the pixels, and how much of the7+// shared vocabulary the build actually declares.78 //89 // Every number is an absolute measurement of one build, never a rank within910 // the collection: ranks move when the collection does, and a merge gate needs
@@ -14,91 +15,16 @@
1415 package main1516 1617 import (17- "archive/zip"18- "encoding/json"1918 "flag"2019 "fmt"2120 "os"2221 "path/filepath"2322 "sort"24- "strconv"25- "strings"2623 2724 "github.com/FelineStateMachine/atlas/internal/bundle"25+ "github.com/FelineStateMachine/atlas/internal/measure"2826 )2927 30-type mapDetail struct {31- Groups []struct {32- Categories []struct {33- IconAsset string `json:"iconAsset"`34- DisplayType string `json:"displayType"`35- Locations []struct {36- ID int64 `json:"id"`37- } `json:"locations"`38- } `json:"categories"`39- } `json:"groups"`40- Merged []struct {41- Source string `json:"source"`42- DonorPins int `json:"donorPins"`43- Matched []struct {44- DistancePx int `json:"px"`45- } `json:"matched"`46- Added int `json:"added"`47- Adopted []struct{} `json:"adopted"`48- Held []struct{} `json:"held"`49- Rejected []struct{} `json:"rejected"`50- Alignment string `json:"alignment"`51- } `json:"merged"`52- Zones []struct {53- Features []struct {54- Coordinates json.RawMessage `json:"coordinates"`55- } `json:"features"`56- } `json:"zones"`57- Variants []struct {58- Name string `json:"name"`59- MaxZoom int `json:"maxZoom"`60- } `json:"variants"`61-}62-63-// measure is one build's absolute account of itself.64-type measure struct {65- File string66- Stamp string67- CreatedAt string68- Revision int69-70- Pins int71- Described int72- MedianLength int73- TileCount int74- RasterBytes int6475- Depth int76- DepthTiles int77- Categories int78- TextSets int79- Groups int80- Zones int81- Vertices int82- Variants int83- IconsCarried int84- IconsWanted int85- Merges []mergeLine86-}87-88-// mergeLine is one source's agreement account within a build, so two builds'89-// lines sit above one another and a policy change reads as the numbers that90-// moved.91-type mergeLine struct {92- Source string93- DonorPins int94- Matched int95- MedianMatchPx int96- Added int97- Adopted int98- Held int99- Rejected int100-}101-10228 func main() {10329 bundles := flag.String("bundles", "",10430 "registry directory holding .atlas bundles (default: the application's own library)")
@@ -126,15 +52,13 @@ func run(dir string) error {
12652 return fmt.Errorf("no bundles in %s", dir)12753 }12854 129- byGame := make(map[string][]measure)130- titles := make(map[string]string)55+ byGame := make(map[string][]*measure.Build)13156 for _, path := range paths {132- slug, title, m, err := measureBundle(path)57+ build, err := measure.MeasureBundle(path)13358 if err != nil {13459 return fmt.Errorf("%s: %w", filepath.Base(path), err)13560 }136- byGame[slug] = append(byGame[slug], m)137- titles[slug] = title61+ byGame[build.GameSlug] = append(byGame[build.GameSlug], build)13862 }13963 14064 slugs := make([]string, 0, len(byGame))
@@ -144,214 +68,52 @@ func run(dir string) error {
14468 sort.Strings(slugs)14569 for _, slug := range slugs {14670 builds := byGame[slug]147- // The registry's own order: newest capture first, then the newer148- // policy revision, then stamp, so the first line of every game is149- // the build the reader sees.150- sort.Slice(builds, func(a, b int) bool {151- if builds[a].CreatedAt != builds[b].CreatedAt {152- return builds[a].CreatedAt > builds[b].CreatedAt153- }154- if builds[a].Revision != builds[b].Revision {155- return builds[a].Revision > builds[b].Revision156- }157- return builds[a].Stamp > builds[b].Stamp158- })159- fmt.Printf("%s (%s)\n", titles[slug], slug)160- for at, m := range builds {71+ // The registry's own order, spelled once in internal/measure, so the72+ // first line of every game is the build the reader sees.73+ sort.Slice(builds, func(a, b int) bool { return measure.Newer(builds[a], builds[b]) })74+ fmt.Printf("%s (%s)\n", builds[0].GameTitle, slug)75+ for at, b := range builds {16176 marker := " "16277 if at == 0 {16378 marker = "serving "16479 }165- fmt.Printf(" %s%s\n", marker, m.File)80+ fmt.Printf(" %s%s\n", marker, b.File)16681 fmt.Printf(" annotation %5d pins · %d described (%s) · median %d chars\n",167- m.Pins, m.Described, percent(m.Described, m.Pins), m.MedianLength)168- fmt.Printf(" cartography %5d tiles · %.1f MB unique raster · depth z%d holds %d tiles\n",169- m.TileCount, float64(m.RasterBytes)/1e6, m.Depth, m.DepthTiles)82+ b.Pins, b.Described, b.DescribedPct(), b.MedianLength)83+ fmt.Printf(" cartography %5d tiles · %s MB unique raster · depth z%d holds %d tiles\n",84+ b.TileCount, b.RasterMB(), b.Depth, b.DepthTiles)17085 fmt.Printf(" structure %5d categories in %d groups · %d text label sets · %d zones (%d vertices) · %d layers\n",171- m.Categories, m.Groups, m.TextSets, m.Zones, m.Vertices, m.Variants)86+ b.Categories, b.Groups, b.TextSets, b.Zones, b.Vertices, b.Variants)17287 fmt.Printf(" icons %5d of %d marker categories carry one (%s)\n",173- m.IconsCarried, m.IconsWanted, percent(m.IconsCarried, m.IconsWanted))174- for _, line := range m.Merges {88+ b.IconsCarried, b.IconsWanted, b.IconPct())89+ fmt.Printf(" conventions %s\n", conventionsLine(b))90+ for _, account := range b.Merges {17591 fmt.Printf(" merge %s: %d donor pins · %d matched (median %dpx) · %d added (%d adopted) · %d held · %d rejected\n",176- line.Source, line.DonorPins, line.Matched, line.MedianMatchPx,177- line.Added, line.Adopted, line.Held, line.Rejected)92+ account.Source, account.DonorPins, account.MatchedN(), account.MedianMatchPx(),93+ account.Added, account.AdoptedN(), account.HeldN(), account.RejectedN())17894 }17995 }18096 }18197 return nil18298 }18399 184-func percent(part, whole int) string {185- if whole == 0 {186- return "—"187- }188- return strconv.Itoa(part*100/whole) + "%"189-}190-191-func measureBundle(path string) (string, string, measure, error) {192- reader, err := zip.OpenReader(path)193- if err != nil {194- return "", "", measure{}, err195- }196- defer reader.Close()197-198- entries := make(map[string]*zip.File, len(reader.File))199- for _, file := range reader.File {200- entries[file.Name] = file100+// conventionsLine says how much of the shared vocabulary a build speaks. A101+// build from before the conventions says so plainly rather than scoring102+// zero on a test it never sat.103+func conventionsLine(b *measure.Build) string {104+ if b.Conventions == 0 {105+ return "none declared (pre-conventions build)"201106 }202- var manifest bundle.Manifest203- if err := readEntry(entries, "atlas.json", &manifest); err != nil {204- return "", "", measure{}, err107+ line := fmt.Sprintf("v%d · %s of categories declare their rendering · geometry %s",108+ b.Conventions, b.RenderPct(), b.Geometry)109+ if b.StdIcons > 0 {110+ line += fmt.Sprintf(" · %d standard icons", b.StdIcons)205111 }206-207- m := measure{208- File: filepath.Base(path),209- Stamp: manifest.Version.Stamp,210- CreatedAt: manifest.Version.CreatedAt,211- Revision: manifest.Version.Revision,112+ if b.GeoPins > 0 {113+ line += fmt.Sprintf(" · %d pins carry true coordinates", b.GeoPins)212114 }213-214- // Annotation: how thoroughly the locations are explained in writing.215- var lengths []int216- for _, entry := range manifest.Maps {217- m.Pins += entry.PinCount218- var text map[string]struct {219- Description string `json:"d"`220- }221- if err := readEntry(entries, "maps/"+entry.Slug+".text", &text); err != nil {222- return "", "", measure{}, err223- }224- for _, held := range text {225- cleaned := strings.Join(strings.Fields(held.Description), " ")226- if cleaned == "" {227- continue228- }229- m.Described++230- lengths = append(lengths, len(cleaned))231- }232-233- var detail mapDetail234- if err := readEntry(entries, "maps/"+entry.Slug+".json", &detail); err != nil {235- return "", "", measure{}, err236- }237- m.Variants += len(detail.Variants)238- for _, variant := range detail.Variants {239- m.Depth = max(m.Depth, variant.MaxZoom)240- }241- m.Zones += len(detail.Zones)242- for _, zone := range detail.Zones {243- for _, feature := range zone.Features {244- m.Vertices += countVertices(feature.Coordinates)245- }246- }247- for _, group := range detail.Groups {248- m.Groups++249- for _, category := range group.Categories {250- m.Categories++251- if category.DisplayType == "text" {252- m.TextSets++253- continue254- }255- m.IconsWanted++256- if category.IconAsset != "" {257- m.IconsCarried++258- }259- }260- }261- for _, merged := range detail.Merged {262- distances := make([]int, 0, len(merged.Matched))263- for _, pair := range merged.Matched {264- distances = append(distances, pair.DistancePx)265- }266- sort.Ints(distances)267- median := 0268- if len(distances) > 0 {269- median = distances[len(distances)/2]270- }271- m.Merges = append(m.Merges, mergeLine{272- Source: merged.Source,273- DonorPins: merged.DonorPins,274- Matched: len(merged.Matched),275- MedianMatchPx: median,276- Added: merged.Added,277- Adopted: len(merged.Adopted),278- Held: len(merged.Held),279- Rejected: len(merged.Rejected),280- })281- }282- }283- sort.Ints(lengths)284- if len(lengths) > 0 {285- m.MedianLength = lengths[len(lengths)/2]286- }287-288- // Cartography: the stored representation, de-duplicated -- a border of289- // repeated filler tiles is one tile's worth of information however many290- // times it appears.291- seen := make(map[[2]uint64]bool)292- for name, file := range entries {293- if !strings.HasPrefix(name, "tiles/") {294- continue295- }296- m.TileCount++297- key := [2]uint64{uint64(file.CRC32), file.UncompressedSize64}298- if !seen[key] {299- seen[key] = true300- m.RasterBytes += int64(file.UncompressedSize64)301- }302- if zoom := tileZoom(name); zoom == m.Depth {303- m.DepthTiles++304- }305- }306-307- return manifest.Game.Slug, manifest.Game.Title, m, nil308-}309-310-func readEntry(entries map[string]*zip.File, name string, into any) error {311- file, ok := entries[name]312- if !ok {313- return fmt.Errorf("%s is missing", name)314- }315- opened, err := file.Open()316- if err != nil {317- return err318- }319- defer opened.Close()320- return json.NewDecoder(opened).Decode(into)321-}322-323-// tileZoom reads the level out of tiles/<pyramid>/<zoom>/<x>/<y>.<ext>.324-func tileZoom(name string) int {325- parts := strings.Split(name, "/")326- if len(parts) < 5 {327- return -1328- }329- zoom, err := strconv.Atoi(parts[len(parts)-3])330- if err != nil {331- return -1332- }333- return zoom334-}335-336-// countVertices counts coordinate pairs through however many rings and337-// nestings a geometry holds.338-func countVertices(raw json.RawMessage) int {339- var value any340- if err := json.Unmarshal(raw, &value); err != nil {341- return 0342- }343- numbers := 0344- var walk func(any)345- walk = func(node any) {346- switch typed := node.(type) {347- case []any:348- for _, child := range typed {349- walk(child)350- }351- case float64:352- numbers++353- }115+ if b.UnknownAttrs > 0 {116+ line += fmt.Sprintf(" · %d attributes this build does not know", b.UnknownAttrs)354117 }355- walk(value)356- return numbers / 2118+ return line357119 }