1package workbench23import (4 "fmt"5 "sort"6 "strconv"78 "github.com/FelineStateMachine/atlas/internal/enrich/maturity"9)1011// Two builds of one volume, read side by side.12//13// A diff says what moved, and it is headlined by the one number that decides14// anything: the score (issue #5 §5.3). Everything under the headline is15// diagnostic -- the absolute axes, the features themselves, the stability of16// the merge ledgers' agreements -- and everything is computed from two builds17// already on disk, because comparing builds is how a policy change is judged18// and a judgement must not depend on anything but its subjects.1920// buildDiff is everything that moved between build A and build B, read in the21// A-to-B direction: an addition is something B has that A did not.22type buildDiff struct {23 A, B *maturity.Score24 // Movement is the score delta, with what the ledger accounts for. It is25 // the headline; the gate of docs/enrich.md reads the same comparison.26 Movement maturity.Comparison27 Worlds []deltaRow28 Axes []deltaRow29 Added []featureRef30 Removed []featureRef31 Pairs []pairStability32}3334// Verdict is the headline in one word: how the score moved, and whether the two35// builds are comparable at all.36func (d *buildDiff) Verdict() string {37 switch {38 case !d.Movement.Comparable:39 return "not comparable"40 case d.Movement.Delta > 0:41 return "richer"42 case d.Movement.Delta < 0 && -d.Movement.Delta <= d.Movement.Allowance:43 return "corrected"44 case d.Movement.Delta < 0:45 return "poorer"46 }47 return "unmoved"48}4950// Delta is the score movement, signed.51func (d *buildDiff) Delta() string { return signedInt(d.Movement.Delta) }5253// deltaRow is one measurement in both builds with its movement, formatted for54// the page; Sign carries the direction for styling without re-parsing.55type deltaRow struct {56 Name string57 A, B string58 Delta string59 Sign int60}6162// featureRef names one point feature as the packed payload carries it.63type featureRef struct {64 World string65 ID int6466 Title string67}6869// pairStability is one (world, source) ledger's agreement compared across the70// two builds: pairs standing in both, pairs only the newer build made, and71// pairs the newer build gave up. A pair is the same pair only when both its72// donor and its winner agree -- a match that moved to another winner reads as73// one lost and one gained, which is exactly the event a policy diff is looking74// for.75type pairStability struct {76 World string77 Source string78 Kept int79 Gained int80 Lost int81}8283// diffBuilds compares two scored builds and their unpacked features.84func diffBuilds(a, b *maturity.Score, featuresA, featuresB map[string]map[int64]string) *buildDiff {85 d := &buildDiff{A: a, B: b, Movement: maturity.Compare(a, b)}86 d.Worlds = worldDeltas(a, b)87 d.Axes = axisDeltas(a.Axes, b.Axes)88 d.Added = featuresOnly(featuresB, featuresA)89 d.Removed = featuresOnly(featuresA, featuresB)90 d.Pairs = pairDiff(a, b)91 return d92}9394// worldDeltas is the score itself, broken down the way it was summed: every95// world either build carries, with what it is worth in each.96func worldDeltas(a, b *maturity.Score) []deltaRow {97 totals := map[string][2]int{}98 var order []string99 take := func(score *maturity.Score, side int) {100 for _, world := range score.Worlds {101 held, seen := totals[world.Slug]102 if !seen {103 order = append(order, world.Slug)104 }105 held[side] = world.Total106 totals[world.Slug] = held107 }108 }109 take(a, 0)110 take(b, 1)111 sort.Strings(order)112113 rows := make([]deltaRow, 0, len(order))114 for _, slug := range order {115 held := totals[slug]116 rows = append(rows, count(slug, held[0], held[1]))117 }118 return rows119}120121// axisDeltas is the five absolute axes, carried from the reference workbench as122// diagnostics. Nothing here gates anything; it is what a person reads to123// understand a score that moved.124func axisDeltas(a, b maturity.Axes) []deltaRow {125 return []deltaRow{126 count("point features", a.Points, b.Points),127 count("features of every kind", a.Features, b.Features),128 count("described", a.Described, b.Described),129 share("described share", a.Described, a.Features, b.Described, b.Features),130 count("median prose chars", a.MedianLength, b.MedianLength),131 count("tiles", a.TileCount, b.TileCount),132 megabytes("unique raster", a.RasterBytes, b.RasterBytes),133 count("depth", a.Depth, b.Depth),134 count("tiles at depth", a.DepthTiles, b.DepthTiles),135 count("collections", a.Collections, b.Collections),136 count("groups", a.Groups, b.Groups),137 count("text label sets", a.TextSets, b.TextSets),138 count("shapes", a.Shapes, b.Shapes),139 count("shape vertices", a.Vertices, b.Vertices),140 count("lenses", a.Lenses, b.Lenses),141 count("icons carried", a.IconsCarried, b.IconsCarried),142 share("icon coverage", a.IconsCarried, a.IconsWanted, b.IconsCarried, b.IconsWanted),143 count("conventions", a.Conventions, b.Conventions),144 share("render declared", a.RenderDeclared, a.Collections, b.RenderDeclared, b.Collections),145 count("standard icons", a.StdIcons, b.StdIcons),146 count("features with coordinates", a.GeoFeatures, b.GeoFeatures),147 count("memberships joined", a.Memberships, b.Memberships),148 count("unknown attributes", a.UnknownAttrs, b.UnknownAttrs),149 }150}151152// featuresOnly lists the features in have that missing lacks, by world and id,153// in a stable order.154func featuresOnly(have, missing map[string]map[int64]string) []featureRef {155 var only []featureRef156 for world, features := range have {157 for id, title := range features {158 if _, held := missing[world][id]; !held {159 only = append(only, featureRef{World: world, ID: id, Title: title})160 }161 }162 }163 sort.Slice(only, func(i, j int) bool {164 if only[i].World != only[j].World {165 return only[i].World < only[j].World166 }167 if only[i].Title != only[j].Title {168 return only[i].Title < only[j].Title169 }170 return only[i].ID < only[j].ID171 })172 return only173}174175func pairDiff(a, b *maturity.Score) []pairStability {176 type key struct{ world, source string }177 type pair struct{ donor, winner int64 }178 collect := func(from *maturity.Score) map[key]map[pair]bool {179 held := make(map[key]map[pair]bool)180 for _, line := range from.Ledger {181 at := key{line.World, line.Account.Source}182 if held[at] == nil {183 held[at] = make(map[pair]bool)184 }185 for _, matched := range line.Account.Matched {186 held[at][pair{matched.Donor, matched.Winner}] = true187 }188 }189 return held190 }191 inA, inB := collect(a), collect(b)192193 keys := make(map[key]bool)194 for at := range inA {195 keys[at] = true196 }197 for at := range inB {198 keys[at] = true199 }200 ordered := make([]key, 0, len(keys))201 for at := range keys {202 ordered = append(ordered, at)203 }204 sort.Slice(ordered, func(i, j int) bool {205 if ordered[i].world != ordered[j].world {206 return ordered[i].world < ordered[j].world207 }208 return ordered[i].source < ordered[j].source209 })210211 var stability []pairStability212 for _, at := range ordered {213 line := pairStability{World: at.world, Source: at.source}214 for held := range inA[at] {215 if inB[at][held] {216 line.Kept++217 } else {218 line.Lost++219 }220 }221 for held := range inB[at] {222 if !inA[at][held] {223 line.Gained++224 }225 }226 stability = append(stability, line)227 }228 return stability229}230231func count(name string, a, b int) deltaRow {232 return deltaRow{233 Name: name,234 A: strconv.Itoa(a),235 B: strconv.Itoa(b),236 Delta: signedInt(b - a),237 Sign: signOf(b - a),238 }239}240241func megabytes(name string, a, b int64) deltaRow {242 row := deltaRow{243 Name: name,244 A: fmt.Sprintf("%.1f MB", float64(a)/1e6),245 B: fmt.Sprintf("%.1f MB", float64(b)/1e6),246 Sign: signOf(int(b - a)),247 }248 if row.Sign == 0 {249 row.Delta = "±0.0 MB"250 } else {251 row.Delta = fmt.Sprintf("%+.1f MB", float64(b-a)/1e6)252 }253 return row254}255256// share compares two ratios in whole points, so coverage reads as a signed257// number of points rather than as two percentages a reader has to subtract.258func share(name string, aPart, aWhole, bPart, bWhole int) deltaRow {259 points := func(part, whole int) int {260 if whole == 0 {261 return 0262 }263 return part * 100 / whole264 }265 delta := points(bPart, bWhole) - points(aPart, aWhole)266 return deltaRow{267 Name: name,268 A: percent(aPart, aWhole),269 B: percent(bPart, bWhole),270 Delta: signedInt(delta) + " pt",271 Sign: signOf(delta),272 }273}274275func percent(part, whole int) string {276 if whole == 0 {277 return "—"278 }279 return strconv.Itoa(part*100/whole) + "%"280}281282func signedInt(delta int) string {283 if delta > 0 {284 return "+" + strconv.Itoa(delta)285 }286 if delta < 0 {287 return strconv.Itoa(delta)288 }289 return "±0"290}291292func signOf(delta int) int {293 switch {294 case delta > 0:295 return 1296 case delta < 0:297 return -1298 }299 return 0300}