clone

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

archive/golden-reference
atlas / internal / measure / measure.go
measure.go 14.3K
1// Package measure is the one place a build of a volume 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.13package measure1415import (16 "archive/zip"17 "encoding/json"18 "fmt"19 "path/filepath"20 "sort"21 "strconv"22 "strings"2324 "github.com/FelineStateMachine/atlas/internal/bundle"25 "github.com/FelineStateMachine/atlas/internal/semconv"26)2728// Build is one .atlas file measured whole: its identity, the axes, and every29// merge account its payloads carry.30type Build struct {31 Path string32 File string33 VolumeSlug string34 VolumeTitle string35 Stamp string36 CreatedAt string37 Revision int38 MapSlugs []string3940 Pins int41 Described int42 MedianLength int43 TileCount int44 RasterBytes int6445 Depth int46 DepthTiles int47 Categories int48 Groups int49 TextSets int50 Zones int51 Vertices int52 Lenses int53 IconsCarried int54 IconsWanted int5556 // 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 int6667 Merges []MergeAccount68}6970// MergeAccount is one source's account of its merge into one world, kept whole71// rather than counted: the pairs and the held pins are what a diff compares.72type MergeAccount struct {73 Map string74 Source string75 DonorPins int76 // DonorFeatures is the donor's offering counted per kind. Bundles from77 // before the ledger spoke features carry only donorPins; for those the78 // point count stands in and the shapes read zero.79 DonorFeatures FeatureCounts80 Matched []MatchedPair81 Added int82 // AddedShapes is the ledger's reserved shape-merge count, zero in every83 // bundle written so far.84 AddedShapes int85 Adopted []AdoptedPin86 Held []HeldPin87 Rejected []HeldPin88 Alignment string89}9091// FeatureCounts counts donor features by kind, in the ledger's own words.92type FeatureCounts struct {93 Point int `json:"point"`94 Path int `json:"path"`95 Area int `json:"area"`96}9798// MatchedPair records one place both sources pin, in the ledger's own words.99type MatchedPair struct {100 Donor int64 `json:"d"`101 Winner int64 `json:"w"`102 DistancePx int `json:"px"`103 Enriched bool `json:"e"`104}105106// AdoptedPin records a donor-only pin that joined one of the serving world's107// own categories.108type AdoptedPin struct {109 Donor int64 `json:"d"`110 Into string `json:"into"`111}112113// HeldPin is a donor pin the merge did not carry, with the reason.114type HeldPin struct {115 Donor int64 `json:"d"`116 Title string `json:"t"`117 Reason string `json:"why"`118}119120// worldDetail is the sliver of a world payload measurement reads.121type worldDetail struct {122 Attrs map[string]string `json:"attrs"`123 Collections []struct {124 Kind string `json:"kind"`125 Group string `json:"group"`126 IconAsset string `json:"iconAsset"`127 Attrs map[string]string `json:"attrs"`128 Features []struct {129 Geometry []struct {130 Coordinates json.RawMessage `json:"coordinates"`131 } `json:"geometry"`132 } `json:"features"`133 } `json:"collections"`134 Merged []struct {135 Source string `json:"source"`136 DonorPins int `json:"donorPins"`137 DonorFeatures *FeatureCounts `json:"donorFeatures"`138 Matched []MatchedPair `json:"matched"`139 Added int `json:"added"`140 AddedShapes int `json:"addedShapes"`141 Adopted []AdoptedPin `json:"adopted"`142 Held []HeldPin `json:"held"`143 Rejected []HeldPin `json:"rejected"`144 Alignment string `json:"alignment"`145 } `json:"merged"`146 Lenses []struct {147 Name string `json:"name"`148 MaxZoom int `json:"maxZoom"`149 } `json:"lenses"`150}151152// MedianMatchPx is the middle distance of the account's matched pairs: the153// figure that says how well the alignment held, unmoved by one outlier.154func (m MergeAccount) MedianMatchPx() int {155 if len(m.Matched) == 0 {156 return 0157 }158 distances := make([]int, 0, len(m.Matched))159 for _, pair := range m.Matched {160 distances = append(distances, pair.DistancePx)161 }162 sort.Ints(distances)163 return distances[len(distances)/2]164}165166// DonorShapesN is how many shape features -- paths and areas together --167// the donor offered; none of them merge yet, so each one shows up again in168// the held ledger.169func (m MergeAccount) DonorShapesN() int { return m.DonorFeatures.Path + m.DonorFeatures.Area }170171func (m MergeAccount) MatchedN() int { return len(m.Matched) }172func (m MergeAccount) AdoptedN() int { return len(m.Adopted) }173func (m MergeAccount) HeldN() int { return len(m.Held) }174func (m MergeAccount) RejectedN() int { return len(m.Rejected) }175176// DescribedPct is the annotation share as pages print it.177func (b *Build) DescribedPct() string { return Percent(b.Described, b.Pins) }178179// IconPct is the icon coverage as pages print it.180func (b *Build) IconPct() string { return Percent(b.IconsCarried, b.IconsWanted) }181182// RenderPct is the share of categories that declare how they render.183func (b *Build) RenderPct() string { return Percent(b.RenderDeclared, b.Categories) }184185// RasterMB is the unique raster weight in megabytes, one decimal.186func (b *Build) RasterMB() string { return fmt.Sprintf("%.1f", float64(b.RasterBytes)/1e6) }187188// ShortStamp is the stamp as pages carry it.189func (b *Build) ShortStamp() string { return bundle.ShortStamp(b.Stamp) }190191// SourcesSeen lists the distinct sources the build's merge accounts name,192// sorted. A bundle is source-agnostic by design, so the ledger is the only193// place a source's name survives into a build.194func (b *Build) SourcesSeen() []string {195 seen := make(map[string]bool)196 var names []string197 for _, account := range b.Merges {198 if !seen[account.Source] {199 seen[account.Source] = true200 names = append(names, account.Source)201 }202 }203 sort.Strings(names)204 return names205}206207// Newer reports whether a should stand before b: the registry's own fold,208// bundle.MoreRecent, restated over measurements so every report lists builds209// in the order the reader would actually serve them. This is the one copy210// outside the registry itself.211func Newer(a, b *Build) bool {212 if a.CreatedAt != b.CreatedAt {213 return a.CreatedAt > b.CreatedAt214 }215 if a.Revision != b.Revision {216 return a.Revision > b.Revision217 }218 if a.Stamp != b.Stamp {219 return a.Stamp > b.Stamp220 }221 return a.Path > b.Path222}223224// Percent is the share as every page and report prints it, with an em dash225// where there is nothing to take a share of.226func Percent(part, whole int) string {227 if whole == 0 {228 return "—"229 }230 return strconv.Itoa(part*100/whole) + "%"231}232233// MeasureBundle reads one .atlas whole and returns its measurements. It234// reads the zip directly rather than through bundle.Open because the235// cartography axis needs what only the archive's table of contents knows:236// every tile entry's checksum and size, for de-duplicating filler.237func MeasureBundle(path string) (*Build, error) {238 reader, err := zip.OpenReader(path)239 if err != nil {240 return nil, err241 }242 defer reader.Close()243244 entries := make(map[string]*zip.File, len(reader.File))245 for _, file := range reader.File {246 entries[file.Name] = file247 }248 var manifest bundle.Manifest249 if err := readEntry(entries, bundle.ManifestName, &manifest); err != nil {250 return nil, err251 }252 if err := manifest.Validate(); err != nil {253 return nil, err254 }255256 b := &Build{257 Path: path,258 File: filepath.Base(path),259 VolumeSlug: manifest.Volume.Slug,260 VolumeTitle: manifest.Volume.Title,261 Stamp: manifest.Version.Stamp,262 CreatedAt: manifest.Version.CreatedAt,263 Revision: manifest.Version.Revision,264 Conventions: manifest.Conventions,265 Geometry: "plane-default",266 }267268 // Annotation: how thoroughly the locations are explained in writing.269 var lengths []int270 for _, entry := range manifest.Worlds {271 b.MapSlugs = append(b.MapSlugs, entry.Slug)272 b.Pins += entry.Points273 var text map[string]struct {274 Description string `json:"d"`275 Attrs map[string]string `json:"a"`276 }277 if err := readEntry(entries, "worlds/"+entry.Slug+".text", &text); err != nil {278 return nil, err279 }280 for _, held := range text {281 if held.Attrs[semconv.KeyGeoLat] != "" && held.Attrs[semconv.KeyGeoLon] != "" {282 b.GeoPins++283 }284 b.UnknownAttrs += unknownAttrs(held.Attrs)285 cleaned := strings.Join(strings.Fields(held.Description), " ")286 if cleaned == "" {287 continue288 }289 b.Described++290 lengths = append(lengths, len(cleaned))291 }292293 var detail worldDetail294 if err := readEntry(entries, "worlds/"+entry.Slug+".json", &detail); err != nil {295 return nil, err296 }297 b.UnknownAttrs += unknownAttrs(detail.Attrs)298 switch detail.Attrs[semconv.KeyGeometrySurface] {299 case semconv.SurfaceSphere:300 b.Geometry = semconv.SurfaceSphere301 case semconv.SurfacePlane:302 if b.Geometry != semconv.SurfaceSphere {303 b.Geometry = semconv.SurfacePlane304 }305 }306 b.Lenses += len(detail.Lenses)307 for _, lens := range detail.Lenses {308 b.Depth = max(b.Depth, lens.MaxZoom)309 }310 groups := make(map[string]bool)311 for _, collection := range detail.Collections {312 b.UnknownAttrs += unknownAttrs(collection.Attrs)313 if collection.Kind != semconv.GeometryPoint {314 b.Zones += len(collection.Features)315 for _, feature := range collection.Features {316 for _, geometry := range feature.Geometry {317 b.Vertices += countVertices(geometry.Coordinates)318 }319 }320 continue321 }322 b.Categories++323 if !groups[collection.Group] {324 groups[collection.Group] = true325 b.Groups++326 }327 if _, declared := collection.Attrs[semconv.KeyRenderAs]; declared {328 b.RenderDeclared++329 }330 if collection.Attrs[semconv.KeyIconStd] != "" {331 b.StdIcons++332 }333 // The conventions decide what a collection is, so the metric334 // judging it reads the same attribute the viewer does.335 if semconv.RenderAs(collection.Attrs, "") == semconv.RenderAsText {336 b.TextSets++337 continue338 }339 b.IconsWanted++340 if collection.IconAsset != "" {341 b.IconsCarried++342 }343 }344 for _, merged := range detail.Merged {345 // Bundles in the wild predate the per-kind count: where the346 // account says only donorPins, the points stand for the whole347 // offering, exactly as the writer of that day meant it.348 counts := FeatureCounts{Point: merged.DonorPins}349 if merged.DonorFeatures != nil {350 counts = *merged.DonorFeatures351 }352 b.Merges = append(b.Merges, MergeAccount{353 Map: entry.Slug,354 Source: merged.Source,355 DonorPins: counts.Point,356 DonorFeatures: counts,357 Matched: merged.Matched,358 Added: merged.Added,359 AddedShapes: merged.AddedShapes,360 Adopted: merged.Adopted,361 Held: merged.Held,362 Rejected: merged.Rejected,363 Alignment: merged.Alignment,364 })365 }366 }367 sort.Ints(lengths)368 if len(lengths) > 0 {369 b.MedianLength = lengths[len(lengths)/2]370 }371372 // Cartography: the stored representation, de-duplicated -- a border of373 // repeated filler tiles is one tile's worth of information however many374 // times it appears.375 seen := make(map[[2]uint64]bool)376 for name, file := range entries {377 if !strings.HasPrefix(name, "tiles/") {378 continue379 }380 b.TileCount++381 key := [2]uint64{uint64(file.CRC32), file.UncompressedSize64}382 if !seen[key] {383 seen[key] = true384 b.RasterBytes += int64(file.UncompressedSize64)385 }386 if zoom := tileZoom(name); zoom == b.Depth {387 b.DepthTiles++388 }389 }390 return b, nil391}392393// unknownAttrs counts the atlas-namespace keys the registry does not know.394// Validation refuses them at the producer, so a count above zero means a395// bundle written by a newer vocabulary than this build of the tools.396func unknownAttrs(attrs map[string]string) int {397 unknown := 0398 for key := range attrs {399 if !strings.HasPrefix(key, "atlas.") {400 continue401 }402 if _, known := semconv.EntityOf(key); !known {403 unknown++404 }405 }406 return unknown407}408409// LoadPins reads the packed locations of every world, keyed the way a diff410// compares them: by world slug, then pin id.411func LoadPins(path string, worldSlugs []string) (map[string]map[int64]string, error) {412 opened, err := bundle.Open(path)413 if err != nil {414 return nil, err415 }416 defer opened.Close()417418 pins := make(map[string]map[int64]string, len(worldSlugs))419 for _, slug := range worldSlugs {420 packed, err := opened.ReadEntry("worlds/" + slug + ".bin")421 if err != nil {422 return nil, fmt.Errorf("map %s: %w", slug, err)423 }424 locations, err := bundle.UnpackLocations(packed)425 if err != nil {426 return nil, fmt.Errorf("map %s: %w", slug, err)427 }428 titles := make(map[int64]string, len(locations))429 for _, location := range locations {430 titles[location.ID] = location.Title431 }432 pins[slug] = titles433 }434 return pins, nil435}436437func readEntry(entries map[string]*zip.File, name string, into any) error {438 file, ok := entries[name]439 if !ok {440 return fmt.Errorf("%s is missing", name)441 }442 opened, err := file.Open()443 if err != nil {444 return err445 }446 defer opened.Close()447 return json.NewDecoder(opened).Decode(into)448}449450// tileZoom reads the level out of tiles/<pyramid>/<zoom>/<x>/<y>.<ext>.451func tileZoom(name string) int {452 parts := strings.Split(name, "/")453 if len(parts) < 5 {454 return -1455 }456 zoom, err := strconv.Atoi(parts[len(parts)-3])457 if err != nil {458 return -1459 }460 return zoom461}462463// countVertices counts coordinate pairs through however many rings and464// nestings a geometry holds.465func countVertices(raw json.RawMessage) int {466 var value any467 if err := json.Unmarshal(raw, &value); err != nil {468 return 0469 }470 numbers := 0471 var walk func(any)472 walk = func(node any) {473 switch typed := node.(type) {474 case []any:475 for _, child := range typed {476 walk(child)477 }478 case float64:479 numbers++480 }481 }482 walk(value)483 return numbers / 2484}