Validating a merge policy has meant holding two maturity printouts beside each other and trusting the eye. The workbench now does the holding: pick two builds of one game and the page answers in three registers -- every axis figure with its signed movement, the packed pins that arrived or departed by id and title, and the merge ledgers' agreements compared pair by pair. A pair is the same pair only when donor and winner both agree, so a match that wandered to another winner reads as one lost and one gained -- which is precisely the event a policy diff exists to surface, the one the drop-point audit had to dig out by hand. Shares move in whole points beside their counts, because the coverage rule is about the share: icons must not decline across builds, and now the decline would be a red number rather than a recollection.
- commit
- a175057028c52b76187252d377549608b8df177c
- parent
- 27acbbeead4a50868056c1bd55e3b1e97d33a7fa
- author
- Dami <dami.etoile@gmail.com>
| A | cmd/ | 228 | ++++++++++++++++++++++++++++++ |
| A | cmd/ | 135 | ++++++++++++++++++ |
| M | cmd/ | 34 | ++++- |
| M | cmd/ | 10 | +- |
| A | cmd/ | 58 | ++++++++ |
| M | cmd/ | 13 | ++ |
@@ -0,0 +1,228 @@
1+package main2+3+import (4+ "fmt"5+ "sort"6+ "strconv"7+)8+9+// 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.14+15+// 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.17+type buildDiff struct {18+ A, B *build19+ Axes []axisRow20+ Added []pinRef21+ Removed []pinRef22+ Pairs []pairStability23+}24+25+// axisRow is one measurement in both builds with its movement, formatted for26+// the page; Sign carries the direction for styling without re-parsing.27+type axisRow struct {28+ Name string29+ A, B string30+ Delta string31+ Sign int32+}33+34+// pinRef names one pin as the packed payload carries it.35+type pinRef struct {36+ Map string37+ ID int6438+ Title string39+}40+41+// 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.47+type pairStability struct {48+ Map string49+ Source string50+ Kept int51+ Gained int52+ Lost int53+}54+55+// diffBuilds compares two measured builds and their packed pins.56+func 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.Variants, b.Variants),75+ axisCount("icons carried", a.IconsCarried, b.IconsCarried),76+ axisShare("icon coverage", a.IconsCarried, a.IconsWanted, b.IconsCarried, b.IconsWanted),77+ },78+ }79+80+ d.Added = pinsOnly(pinsB, pinsA)81+ d.Removed = pinsOnly(pinsA, pinsB)82+ d.Pairs = pairDiff(a, b)83+ return d84+}85+86+// pinsOnly lists the pins in have that missing lacks, by map and id, in a87+// stable order.88+func pinsOnly(have, missing map[string]map[int64]string) []pinRef {89+ var only []pinRef90+ for mapSlug, pins := range have {91+ for id, title := range pins {92+ if _, held := missing[mapSlug][id]; !held {93+ only = append(only, pinRef{Map: mapSlug, ID: id, Title: title})94+ }95+ }96+ }97+ sort.Slice(only, func(i, j int) bool {98+ if only[i].Map != only[j].Map {99+ return only[i].Map < only[j].Map100+ }101+ if only[i].Title != only[j].Title {102+ return only[i].Title < only[j].Title103+ }104+ return only[i].ID < only[j].ID105+ })106+ return only107+}108+109+func pairDiff(a, b *build) []pairStability {110+ type key struct{ mapSlug, source string }111+ type pair struct{ donor, winner int64 }112+ collect := func(from *build) map[key]map[pair]bool {113+ held := make(map[key]map[pair]bool)114+ for _, account := range from.Merges {115+ at := key{account.Map, account.Source}116+ if held[at] == nil {117+ held[at] = make(map[pair]bool)118+ }119+ for _, matched := range account.Matched {120+ held[at][pair{matched.Donor, matched.Winner}] = true121+ }122+ }123+ return held124+ }125+ inA, inB := collect(a), collect(b)126+127+ keys := make(map[key]bool)128+ for at := range inA {129+ keys[at] = true130+ }131+ for at := range inB {132+ keys[at] = true133+ }134+ ordered := make([]key, 0, len(keys))135+ for at := range keys {136+ ordered = append(ordered, at)137+ }138+ sort.Slice(ordered, func(i, j int) bool {139+ if ordered[i].mapSlug != ordered[j].mapSlug {140+ return ordered[i].mapSlug < ordered[j].mapSlug141+ }142+ return ordered[i].source < ordered[j].source143+ })144+145+ var stability []pairStability146+ for _, at := range ordered {147+ line := pairStability{Map: at.mapSlug, Source: at.source}148+ for held := range inA[at] {149+ if inB[at][held] {150+ line.Kept++151+ } else {152+ line.Lost++153+ }154+ }155+ for held := range inB[at] {156+ if !inA[at][held] {157+ line.Gained++158+ }159+ }160+ stability = append(stability, line)161+ }162+ return stability163+}164+165+func axisCount(name string, a, b int) axisRow {166+ return axisRow{167+ Name: name,168+ A: strconv.Itoa(a),169+ B: strconv.Itoa(b),170+ Delta: signedInt(b - a),171+ Sign: signOf(b - a),172+ }173+}174+175+func axisBytes(name string, a, b int64) axisRow {176+ delta := float64(b-a) / 1e6177+ row := axisRow{178+ Name: name,179+ A: fmt.Sprintf("%.1f MB", float64(a)/1e6),180+ B: fmt.Sprintf("%.1f MB", float64(b)/1e6),181+ Sign: signOf(int(b - a)),182+ }183+ if row.Sign == 0 {184+ row.Delta = "±0.0 MB"185+ } else {186+ row.Delta = fmt.Sprintf("%+.1f MB", delta)187+ }188+ return row189+}190+191+// axisShare compares two ratios in whole points, so coverage that must not192+// decline across builds reads as a signed number of points.193+func axisShare(name string, aPart, aWhole, bPart, bWhole int) axisRow {194+ points := func(part, whole int) int {195+ if whole == 0 {196+ return 0197+ }198+ return part * 100 / whole199+ }200+ delta := points(bPart, bWhole) - points(aPart, aWhole)201+ return axisRow{202+ Name: name,203+ A: percent(aPart, aWhole),204+ B: percent(bPart, bWhole),205+ Delta: signedInt(delta) + " pt",206+ Sign: signOf(delta),207+ }208+}209+210+func signedInt(delta int) string {211+ if delta > 0 {212+ return "+" + strconv.Itoa(delta)213+ }214+ if delta < 0 {215+ return strconv.Itoa(delta)216+ }217+ return "±0"218+}219+220+func signOf(delta int) int {221+ switch {222+ case delta > 0:223+ return 1224+ case delta < 0:225+ return -1226+ }227+ return 0228+}@@ -0,0 +1,135 @@
1+package main2+3+import (4+ "strings"5+ "testing"6+7+ "github.com/FelineStateMachine/atlas/internal/bundle/bundletest"8+)9+10+// diffFixture builds two builds of one game the way a policy change leaves11+// them: the newer one loses a pin, keeps one matched pair, moves one, and12+// makes one it did not have.13+func diffFixture(t *testing.T) (*build, *build, map[string]map[int64]string, map[string]map[int64]string) {14+ t.Helper()15+ dir := t.TempDir()16+ install(t, dir, "hollowmere-a.atlas", bundletest.Spec{17+ Slug: "hollowmere", Title: "Hollowmere", CreatedAt: "2026-01-01T00:00:00Z",18+ Maps: []bundletest.MapSpec{{19+ Slug: "overworld",20+ Pins: []bundletest.Pin{21+ {Title: "Gate"}, {Title: "Well", Silent: true}, {Title: "Shrine"},22+ },23+ Merged: []map[string]any{{24+ "source": "ign-wiki", "donorPins": 4,25+ "matched": []map[string]any{26+ {"d": 11, "w": 1, "px": 10},27+ {"d": 12, "w": 2, "px": 20},28+ },29+ "alignment": "affine, median 20px",30+ }},31+ }},32+ })33+ install(t, dir, "hollowmere-b.atlas", bundletest.Spec{34+ Slug: "hollowmere", Title: "Hollowmere", CreatedAt: "2026-02-01T00:00:00Z",35+ Maps: []bundletest.MapSpec{{36+ Slug: "overworld",37+ Pins: []bundletest.Pin{38+ {Title: "Gate", Description: "The way in."}, {Title: "Well"},39+ },40+ Merged: []map[string]any{{41+ "source": "ign-wiki", "donorPins": 4,42+ "matched": []map[string]any{43+ {"d": 11, "w": 1, "px": 10},44+ {"d": 12, "w": 3, "px": 30},45+ {"d": 13, "w": 2, "px": 15},46+ },47+ "alignment": "affine, median 15px",48+ }},49+ }},50+ })51+52+ games, _, err := (&library{dir: dir}).games()53+ if err != nil {54+ t.Fatal(err)55+ }56+ held := games[0]57+ older, newer := held.Build("hollowmere-a.atlas"), held.Build("hollowmere-b.atlas")58+ pinsA, err := loadPins(older.Path, older.MapSlugs)59+ if err != nil {60+ t.Fatal(err)61+ }62+ pinsB, err := loadPins(newer.Path, newer.MapSlugs)63+ if err != nil {64+ t.Fatal(err)65+ }66+ return older, newer, pinsA, pinsB67+}68+69+func TestDiffBuilds(t *testing.T) {70+ older, newer, pinsA, pinsB := diffFixture(t)71+ d := diffBuilds(older, newer, pinsA, pinsB)72+73+ // The newer build packs two pins where the older packed three: id 3 is74+ // gone and nothing arrived, since ids 1 and 2 stand in both.75+ if len(d.Added) != 0 {76+ t.Errorf("added %v, want none", d.Added)77+ }78+ if len(d.Removed) != 1 || d.Removed[0].ID != 3 || d.Removed[0].Title != "Shrine" ||79+ d.Removed[0].Map != "overworld" {80+ t.Errorf("removed %v, want overworld pin 3 Shrine", d.Removed)81+ }82+83+ // Pair (11,1) stands in both ledgers. Pair (12,2) became (12,3), which84+ // must read as one lost and one gained -- and (13,2) is a fresh gain.85+ if len(d.Pairs) != 1 {86+ t.Fatalf("pair lines %v, want one", d.Pairs)87+ }88+ line := d.Pairs[0]89+ if line.Map != "overworld" || line.Source != "ign-wiki" {90+ t.Errorf("pair line identity %+v", line)91+ }92+ if line.Kept != 1 || line.Gained != 2 || line.Lost != 1 {93+ t.Errorf("stability kept %d gained %d lost %d, want 1, 2, 1", line.Kept, line.Gained, line.Lost)94+ }95+96+ // Axis deltas read in A-to-B direction.97+ byName := make(map[string]axisRow)98+ for _, row := range d.Axes {99+ byName[row.Name] = row100+ }101+ if row := byName["pins"]; row.Delta != "-1" || row.Sign != -1 {102+ t.Errorf("pins delta %q sign %d", row.Delta, row.Sign)103+ }104+ // Described goes 2 of 3 to 2 of 2: count flat, share up 34 points.105+ if row := byName["described"]; row.Delta != "±0" || row.Sign != 0 {106+ t.Errorf("described delta %q sign %d", row.Delta, row.Sign)107+ }108+ if row := byName["described share"]; row.Delta != "+34 pt" || row.Sign != 1 {109+ t.Errorf("described share delta %q sign %d", row.Delta, row.Sign)110+ }111+ if row := byName["icon coverage"]; row.A != "100%" || row.B != "100%" || row.Sign != 0 {112+ t.Errorf("icon coverage row %+v", row)113+ }114+ if row := byName["unique raster"]; !strings.HasSuffix(row.Delta, "MB") {115+ t.Errorf("raster delta %q carries no unit", row.Delta)116+ }117+}118+119+func TestDiffPage(t *testing.T) {120+ server, _ := testServer(t)121+ ts := newTestSite(t, server)122+123+ status, body := get(t, ts, "/game/hollowmere/diff?a=hollowmere-jan.atlas&b=hollowmere-feb.atlas")124+ if status != 200 {125+ t.Fatalf("diff page answered %d", status)126+ }127+ for _, want := range []string{"what moved", "Matched-pair stability", "described share", "ign-wiki"} {128+ if !strings.Contains(body, want) {129+ t.Errorf("diff page misses %q", want)130+ }131+ }132+ if status, _ := get(t, ts, "/game/hollowmere/diff?a=hollowmere-jan.atlas&b=nowhere.atlas"); status != 400 {133+ t.Errorf("diff with a missing build answered %d", status)134+ }135+}@@ -19,7 +19,7 @@ type server struct {
1919 2020 func newServer(lib *library) *server {2121 pages := make(map[string]*template.Template)22- for _, name := range []string{"collection", "game"} {22+ for _, name := range []string{"collection", "game", "diff"} {2323 pages[name] = template.Must(template.ParseFS(uiFS, "ui/layout.tmpl", "ui/"+name+".tmpl"))2424 }2525 return &server{library: lib, pages: pages}@@ -29,6 +29,7 @@ func (s *server) routes() http.Handler {
2929 mux := http.NewServeMux()3030 mux.HandleFunc("GET /{$}", s.handleCollection)3131 mux.HandleFunc("GET /game/{slug}", s.handleGame)32+ mux.HandleFunc("GET /game/{slug}/diff", s.handleDiff)3233 mux.HandleFunc("GET /static/style.css", staticFile("ui/style.css", "text/css; charset=utf-8"))3334 return mux3435 }@@ -75,6 +76,37 @@ func (s *server) handleGame(w http.ResponseWriter, r *http.Request) {
7576 s.render(w, "game", struct{ Game *game }{Game: held})7677 }7778 79+// handleDiff compares two builds of one game, named by file. The packed pins80+// are read here, on demand, because only a comparison ever needs them whole.81+func (s *server) handleDiff(w http.ResponseWriter, r *http.Request) {82+ held, err := s.library.gameBySlug(r.PathValue("slug"))83+ if err != nil {84+ http.Error(w, err.Error(), http.StatusInternalServerError)85+ return86+ }87+ if held == nil {88+ http.NotFound(w, r)89+ return90+ }91+ from, to := held.Build(r.FormValue("a")), held.Build(r.FormValue("b"))92+ if from == nil || to == nil {93+ http.Error(w, "pick two builds of this game", http.StatusBadRequest)94+ return95+ }96+ pinsA, err := loadPins(from.Path, from.MapSlugs)97+ if err == nil {98+ var pinsB map[string]map[int64]string99+ if pinsB, err = loadPins(to.Path, to.MapSlugs); err == nil {100+ s.render(w, "diff", struct {101+ Game *game102+ Diff *buildDiff103+ }{Game: held, Diff: diffBuilds(from, to, pinsA, pinsB)})104+ return105+ }106+ }107+ http.Error(w, err.Error(), http.StatusInternalServerError)108+}109+78110 func staticFile(name, contentType string) http.HandlerFunc {79111 return func(w http.ResponseWriter, r *http.Request) {80112 data, err := uiFS.ReadFile(name)@@ -27,6 +27,13 @@ func testServer(t *testing.T) (*server, string) {
2727 return newServer(&library{dir: dir}), dir2828 }2929 30+func newTestSite(t *testing.T, s *server) *httptest.Server {31+ t.Helper()32+ ts := httptest.NewServer(s.routes())33+ t.Cleanup(ts.Close)34+ return ts35+}36+3037 func get(t *testing.T, ts *httptest.Server, path string) (int, string) {3138 t.Helper()3239 response, err := ts.Client().Get(ts.URL + path)@@ -43,8 +50,7 @@ func get(t *testing.T, ts *httptest.Server, path string) (int, string) {
4350 4451 func TestPages(t *testing.T) {4552 server, _ := testServer(t)46- ts := httptest.NewServer(server.routes())47- defer ts.Close()53+ ts := newTestSite(t, server)4854 4955 status, body := get(t, ts, "/")5056 if status != http.StatusOK {@@ -0,0 +1,58 @@
1+{{define "title"}}Cartograph · {{.Game.Title}} · diff{{end}}2+{{define "content"}}3+<section class="page-head">4+ <h1>{{.Game.Title}} <span class="dim">what moved</span></h1>5+ <p class="dim"><a href="/game/{{.Game.Slug}}">{{.Game.Slug}}</a> · {{.Diff.A.File}} → {{.Diff.B.File}}</p>6+</section>7+8+<section class="card">9+ <h2>Axes</h2>10+ <table>11+ <thead><tr><th>measure</th><th class="num">{{.Diff.A.ShortStamp}}</th><th class="num">{{.Diff.B.ShortStamp}}</th><th class="num">Δ</th></tr></thead>12+ <tbody>13+ {{range .Diff.Axes}}14+ <tr>15+ <td>{{.Name}}</td><td class="num">{{.A}}</td><td class="num">{{.B}}</td>16+ <td class="num {{if gt .Sign 0}}delta-up{{else if lt .Sign 0}}delta-down{{else}}delta-flat{{end}}">{{.Delta}}</td>17+ </tr>18+ {{end}}19+ </tbody>20+ </table>21+</section>22+23+<div class="columns">24+<section class="card">25+ <h2>Pins added <span class="dim">{{len .Diff.Added}}</span></h2>26+ {{if .Diff.Added}}27+ <table>28+ <thead><tr><th>map</th><th class="num">id</th><th>title</th></tr></thead>29+ <tbody>{{range .Diff.Added}}<tr><td>{{.Map}}</td><td class="num">{{.ID}}</td><td>{{.Title}}</td></tr>{{end}}</tbody>30+ </table>31+ {{else}}<p class="dim">None.</p>{{end}}32+</section>33+<section class="card">34+ <h2>Pins removed <span class="dim">{{len .Diff.Removed}}</span></h2>35+ {{if .Diff.Removed}}36+ <table>37+ <thead><tr><th>map</th><th class="num">id</th><th>title</th></tr></thead>38+ <tbody>{{range .Diff.Removed}}<tr><td>{{.Map}}</td><td class="num">{{.ID}}</td><td>{{.Title}}</td></tr>{{end}}</tbody>39+ </table>40+ {{else}}<p class="dim">None.</p>{{end}}41+</section>42+</div>43+44+<section class="card">45+ <h2>Matched-pair stability</h2>46+ {{if .Diff.Pairs}}47+ <p class="dim">A pair is the same pair only when donor and winner both agree; a match that changed winners reads as one lost, one gained.</p>48+ <table>49+ <thead><tr><th>map</th><th>source</th><th class="num">kept</th><th class="num">gained</th><th class="num">lost</th></tr></thead>50+ <tbody>51+ {{range .Diff.Pairs}}52+ <tr><td>{{.Map}}</td><td>{{.Source}}</td><td class="num">{{.Kept}}</td><td class="num">{{.Gained}}</td><td class="num">{{.Lost}}</td></tr>53+ {{end}}54+ </tbody>55+ </table>56+ {{else}}<p class="dim">Neither build carries a merge ledger.</p>{{end}}57+</section>58+{{end}}@@ -4,6 +4,19 @@
44 <h1>{{.Game.Title}} <span class="dim">{{.Game.Slug}}</span></h1>55 <p class="dim">{{len .Game.Builds}} builds, newest first — the first is the one the reader serves.</p>66 </section>7+{{if gt (len .Game.Builds) 1}}8+<form class="compare" action="/game/{{.Game.Slug}}/diff" method="get">9+ <label>compare</label>10+ <select name="a">11+ {{range $at, $b := .Game.Builds}}<option value="{{$b.File}}"{{if eq $at 1}} selected{{end}}>{{$b.File}}</option>{{end}}12+ </select>13+ <label>against</label>14+ <select name="b">15+ {{range $at, $b := .Game.Builds}}<option value="{{$b.File}}"{{if eq $at 0}} selected{{end}}>{{$b.File}}</option>{{end}}16+ </select>17+ <button>What moved</button>18+</form>19+{{end}}720 {{range $at, $b := .Game.Builds}}821 <section class="build{{if eq $at 0}} serving{{end}}">922 <h2>{{$b.File}}{{if eq $at 0}} <span class="tag">serving</span>{{end}}</h2>