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 / cmd / cartograph / diff.go
diff.go 6.3K
1package main23import (4 "fmt"5 "sort"6 "strconv"7)89// A diff reads two builds of one game side by side and says what moved: the10// axis figures, the pins themselves, and the stability of the merge ledgers'11// agreements. It works entirely over measurements and packed payloads already12// on disk -- comparing builds is how a policy change is validated, so the13// comparison must never depend on anything but the builds.1415// buildDiff is everything that moved between build A and build B, read in16// A-to-B direction: an addition is something B has that A did not.17type buildDiff struct {18 A, B *build19 Axes []axisRow20 Added []pinRef21 Removed []pinRef22 Pairs []pairStability23}2425// axisRow is one measurement in both builds with its movement, formatted for26// the page; Sign carries the direction for styling without re-parsing.27type axisRow struct {28 Name string29 A, B string30 Delta string31 Sign int32}3334// pinRef names one pin as the packed payload carries it.35type pinRef struct {36 Map string37 ID int6438 Title string39}4041// pairStability is one (map, source) ledger's agreement compared across the42// two builds: pairs standing in both, pairs only the newer build made, and43// pairs the newer build gave up. A pair is the same pair only when both its44// donor and its winner agree -- a match that moved to another winner reads45// as one lost and one gained, which is exactly the event a policy diff is46// looking for.47type pairStability struct {48 Map string49 Source string50 Kept int51 Gained int52 Lost int53}5455// diffBuilds compares two measured builds and their packed pins.56func diffBuilds(a, b *build, pinsA, pinsB map[string]map[int64]string) *buildDiff {57 d := &buildDiff{58 A: a,59 B: b,60 Axes: []axisRow{61 axisCount("pins", a.Pins, b.Pins),62 axisCount("described", a.Described, b.Described),63 axisShare("described share", a.Described, a.Pins, b.Described, b.Pins),64 axisCount("median note chars", a.MedianLength, b.MedianLength),65 axisCount("tiles", a.TileCount, b.TileCount),66 axisBytes("unique raster", a.RasterBytes, b.RasterBytes),67 axisCount("depth", a.Depth, b.Depth),68 axisCount("tiles at depth", a.DepthTiles, b.DepthTiles),69 axisCount("categories", a.Categories, b.Categories),70 axisCount("groups", a.Groups, b.Groups),71 axisCount("text label sets", a.TextSets, b.TextSets),72 axisCount("zones", a.Zones, b.Zones),73 axisCount("zone vertices", a.Vertices, b.Vertices),74 axisCount("layers", a.Lenses, b.Lenses),75 axisCount("icons carried", a.IconsCarried, b.IconsCarried),76 axisShare("icon coverage", a.IconsCarried, a.IconsWanted, b.IconsCarried, b.IconsWanted),77 // The conventions axes: what vocabulary each build declares and78 // how much of it its payloads actually speak.79 axisCount("conventions", a.Conventions, b.Conventions),80 axisShare("render declared", a.RenderDeclared, a.Categories, b.RenderDeclared, b.Categories),81 axisCount("standard icons", a.StdIcons, b.StdIcons),82 axisCount("geo pins", a.GeoPins, b.GeoPins),83 axisCount("unknown attributes", a.UnknownAttrs, b.UnknownAttrs),84 },85 }8687 d.Added = pinsOnly(pinsB, pinsA)88 d.Removed = pinsOnly(pinsA, pinsB)89 d.Pairs = pairDiff(a, b)90 return d91}9293// pinsOnly lists the pins in have that missing lacks, by map and id, in a94// stable order.95func pinsOnly(have, missing map[string]map[int64]string) []pinRef {96 var only []pinRef97 for mapSlug, pins := range have {98 for id, title := range pins {99 if _, held := missing[mapSlug][id]; !held {100 only = append(only, pinRef{Map: mapSlug, ID: id, Title: title})101 }102 }103 }104 sort.Slice(only, func(i, j int) bool {105 if only[i].Map != only[j].Map {106 return only[i].Map < only[j].Map107 }108 if only[i].Title != only[j].Title {109 return only[i].Title < only[j].Title110 }111 return only[i].ID < only[j].ID112 })113 return only114}115116func pairDiff(a, b *build) []pairStability {117 type key struct{ mapSlug, source string }118 type pair struct{ donor, winner int64 }119 collect := func(from *build) map[key]map[pair]bool {120 held := make(map[key]map[pair]bool)121 for _, account := range from.Merges {122 at := key{account.Map, account.Source}123 if held[at] == nil {124 held[at] = make(map[pair]bool)125 }126 for _, matched := range account.Matched {127 held[at][pair{matched.Donor, matched.Winner}] = true128 }129 }130 return held131 }132 inA, inB := collect(a), collect(b)133134 keys := make(map[key]bool)135 for at := range inA {136 keys[at] = true137 }138 for at := range inB {139 keys[at] = true140 }141 ordered := make([]key, 0, len(keys))142 for at := range keys {143 ordered = append(ordered, at)144 }145 sort.Slice(ordered, func(i, j int) bool {146 if ordered[i].mapSlug != ordered[j].mapSlug {147 return ordered[i].mapSlug < ordered[j].mapSlug148 }149 return ordered[i].source < ordered[j].source150 })151152 var stability []pairStability153 for _, at := range ordered {154 line := pairStability{Map: at.mapSlug, Source: at.source}155 for held := range inA[at] {156 if inB[at][held] {157 line.Kept++158 } else {159 line.Lost++160 }161 }162 for held := range inB[at] {163 if !inA[at][held] {164 line.Gained++165 }166 }167 stability = append(stability, line)168 }169 return stability170}171172func axisCount(name string, a, b int) axisRow {173 return axisRow{174 Name: name,175 A: strconv.Itoa(a),176 B: strconv.Itoa(b),177 Delta: signedInt(b - a),178 Sign: signOf(b - a),179 }180}181182func axisBytes(name string, a, b int64) axisRow {183 delta := float64(b-a) / 1e6184 row := axisRow{185 Name: name,186 A: fmt.Sprintf("%.1f MB", float64(a)/1e6),187 B: fmt.Sprintf("%.1f MB", float64(b)/1e6),188 Sign: signOf(int(b - a)),189 }190 if row.Sign == 0 {191 row.Delta = "±0.0 MB"192 } else {193 row.Delta = fmt.Sprintf("%+.1f MB", delta)194 }195 return row196}197198// axisShare compares two ratios in whole points, so coverage that must not199// decline across builds reads as a signed number of points.200func axisShare(name string, aPart, aWhole, bPart, bWhole int) axisRow {201 points := func(part, whole int) int {202 if whole == 0 {203 return 0204 }205 return part * 100 / whole206 }207 delta := points(bPart, bWhole) - points(aPart, aWhole)208 return axisRow{209 Name: name,210 A: percent(aPart, aWhole),211 B: percent(bPart, bWhole),212 Delta: signedInt(delta) + " pt",213 Sign: signOf(delta),214 }215}216217func signedInt(delta int) string {218 if delta > 0 {219 return "+" + strconv.Itoa(delta)220 }221 if delta < 0 {222 return strconv.Itoa(delta)223 }224 return "±0"225}226227func signOf(delta int) int {228 switch {229 case delta > 0:230 return 1231 case delta < 0:232 return -1233 }234 return 0235}