clone

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

main
atlas / internal / enrich / ledger.go
ledger.go 11.2K
1package enrich23import (4 "fmt"5 "sort"67 "github.com/FelineStateMachine/atlas/format/semconv"8)910// The ledger is how the enrich lane accounts for itself.11//12// Every contribution to a world is written down in one vocabulary, whatever13// made it: what was recognized as something the world already held, what was14// added, what joined a collection the world already had, what was held back15// undecided, and what was refused outright. The vocabulary is small on purpose16// -- five words -- because a ledger nobody can hold in their head is a log, and17// a log is not an account.18//19// matched the world already held this, and the two are one thing20// added the world did not hold this, and now does21// adopted an added thing joined a collection the world already had22// held the contribution could not decide, and says why23// rejected the contribution refused this outright, and says why24//25// The gate below reads an account back and fails the build when it does not add26// up. A merge that quietly lost a feature, or quietly agreed with itself twice27// about one place, is worse than a merge that did not happen.2829// Account is one contributor's account of what it did to one world. The origin30// account is the world's own: where the ground came from, and what it held when31// it got there.32type Account struct {33 // Source is the contributor spelled for a person, Slug the name a registry34 // knows it by.35 Source string `json:"source"`36 Slug string `json:"slug,omitempty"`37 // Origin marks the account of the source the world itself came from, as38 // against a contribution folded in later.39 Origin bool `json:"origin,omitempty"`40 // DonorFeatures is the whole offering counted per kind. On an origin41 // account it is simply the world's own tally.42 DonorFeatures Counts `json:"donorFeatures"`43 // The order of the fields from here down is the order they are written in,44 // and it is frozen rather than incidental: an account rides a world payload45 // and a world payload is a stamped part, so re-spelling one restamps every46 // build in every library.47 Matched []MatchedPair `json:"matched,omitempty"`48 // Added is how many point features the world did not have and now has.49 Added int `json:"added"`50 // AddedShapes holds the ledger's place for the day a shape feature merges.51 // Nothing writes it yet, and the gate refuses a non-zero value rather than52 // letting a number nobody produced pass for an account.53 AddedShapes int `json:"addedShapes,omitempty"`54 Adopted []AdoptedItem `json:"adopted,omitempty"`55 Held []HeldItem `json:"held,omitempty"`56 Rejected []HeldItem `json:"rejected,omitempty"`57 Enriched []CollectionTake `json:"enrichedCategories,omitempty"`58 // Alignment is what the fit this account stood on reported, where it stood59 // on one.60 Alignment string `json:"alignment,omitempty"`61 // Corrections are the ledgered removals of data that was wrong. Nothing in62 // this lane removes anything, so they are always empty today; they exist63 // because the maturity gate's one permitted decrease is a correction, and a64 // permitted decrease has to be permitted by something written down.65 Corrections []Correction `json:"corrections,omitempty"`66}6768// Counts is features by kind, as a ledger speaks of them.69type Counts struct {70 Point int `json:"point"`71 Path int `json:"path"`72 Area int `json:"area"`73}7475// Total is every kind together.76func (c Counts) Total() int { return c.Point + c.Path + c.Area }7778// MatchedPair records one thing both readings hold: the contributor's feature,79// the serving feature it resolved to, how far apart the alignment put them, and80// every attribute the serving feature took from the contributor's.81type MatchedPair struct {82 Donor int64 `json:"d"`83 Winner int64 `json:"w"`84 DistancePx int `json:"px"`85 // Enriched marks a pair whose serving feature had nothing to say and took86 // the contributor's prose. It is derived from Took and kept because readers87 // of the older spelling still ask for it.88 Enriched bool `json:"e,omitempty"`89 Took []string `json:"took,omitempty"`90}9192// AdoptedItem records a contributed feature that joined one of the serving93// world's own collections: provenance for a feature the legend does not single94// out.95type AdoptedItem struct {96 Donor int64 `json:"d"`97 Into string `json:"into"`98}99100// HeldItem is a contributed feature that was not carried, with the reason.101type HeldItem struct {102 Donor int64 `json:"d"`103 Title string `json:"t"`104 Reason string `json:"why"`105}106107// CollectionTake records one attribute a serving collection took from its108// counterpart: which collection, which key.109type CollectionTake struct {110 Collection string `json:"cat"`111 Key string `json:"k"`112}113114// Correction is data removed because it was wrong, and why. The maturity gate115// permits a decrease worth this many points; nothing else in the lane reads it.116type Correction struct {117 World string `json:"world,omitempty"`118 Reason string `json:"reason"`119 Points int `json:"points"`120}121122// HeldShapeReason is the one reason a contributed shape feature is held:123// matching is point-only, so every contributed path and area goes on the record124// rather than vanishing. The gate reads the reason back off the ledger to tell125// shape holds from point holds.126const HeldShapeReason = "shape features do not merge yet"127128// MatchedN, AdoptedN, HeldN and RejectedN are the counts a report prints.129func (a Account) MatchedN() int { return len(a.Matched) }130func (a Account) AdoptedN() int { return len(a.Adopted) }131func (a Account) HeldN() int { return len(a.Held) }132func (a Account) RejectedN() int { return len(a.Rejected) }133134// MedianMatchPx is the middle distance of the account's matched pairs: the135// figure that says how well the alignment held, unmoved by one outlier.136func (a Account) MedianMatchPx() int {137 if len(a.Matched) == 0 {138 return 0139 }140 distances := make([]int, 0, len(a.Matched))141 for _, pair := range a.Matched {142 distances = append(distances, pair.DistancePx)143 }144 sort.Ints(distances)145 return distances[len(distances)/2]146}147148// EnrichedN is how many matched pairs took prose they did not have.149func (a Account) EnrichedN() int {150 count := 0151 for _, pair := range a.Matched {152 if pair.Enriched {153 count++154 }155 }156 return count157}158159// GateAccount is the ledger's own audit of one account: every offered feature160// of every kind accounted for, every match one-to-one, and every attribute a161// take claims answering to the registry.162//163// It fails the build rather than letting a bundle be written that quietly lost164// something -- or quietly agreed too much.165func GateAccount(a Account) error {166 if a.Origin {167 if a.Added != 0 || len(a.Matched) > 0 || len(a.Held) > 0 || len(a.Rejected) > 0 {168 return fmt.Errorf("the origin account of %s claims a contribution; "+169 "an origin account only says what the world arrived with", a.Source)170 }171 return nil172 }173 heldPoints, heldShapes := 0, 0174 for _, held := range a.Held {175 if held.Reason == HeldShapeReason {176 heldShapes++177 } else {178 heldPoints++179 }180 }181 accounted := len(a.Matched) + a.Added + heldPoints + len(a.Rejected)182 if accounted != a.DonorFeatures.Point {183 return fmt.Errorf("%s accounts for %d of %d offered points", a.Source, accounted, a.DonorFeatures.Point)184 }185 if shapes := a.DonorFeatures.Path + a.DonorFeatures.Area; heldShapes != shapes {186 return fmt.Errorf("%s holds %d shape features of the %d it offered", a.Source, heldShapes, shapes)187 }188 if a.AddedShapes != 0 {189 return fmt.Errorf("%s claims %d added shapes; no shape feature merges yet", a.Source, a.AddedShapes)190 }191 claimed := make(map[int64]int64, len(a.Matched))192 for _, pair := range a.Matched {193 if first, taken := claimed[pair.Winner]; taken {194 return fmt.Errorf("serving feature %d matched by %d and %d; a place is one place",195 pair.Winner, first, pair.Donor)196 }197 claimed[pair.Winner] = pair.Donor198 tookProse := false199 for _, key := range pair.Took {200 if key == semconv.KeyNoteText {201 tookProse = true202 continue203 }204 if entity, known := semconv.EntityOf(key); !known || entity != semconv.EntityFeature {205 return fmt.Errorf("pair %d took %q, which no feature may carry", pair.Donor, key)206 }207 }208 if pair.Enriched != tookProse {209 return fmt.Errorf("pair %d says enriched=%t but its takes say %t",210 pair.Donor, pair.Enriched, tookProse)211 }212 }213 for _, take := range a.Enriched {214 if entity, known := semconv.EntityOf(take.Key); !known || entity != semconv.EntityCollection {215 return fmt.Errorf("collection %q took %q, which no collection may carry", take.Collection, take.Key)216 }217 }218 return nil219}220221// GateWorld holds a world to its own ledger: one identifier space across every222// kind, and a tally that is the sum of what every account claims. The counts a223// world reports are not fields that may drift -- they are what its accounts add224// up to, and the world has to actually hold that many of each kind.225func GateWorld(w *World) error {226 seen := make(map[int64]string)227 var counted Counts228 for _, collection := range w.Collections {229 for _, feature := range collection.Features {230 if holder, taken := seen[feature.ID]; taken {231 return fmt.Errorf("world %s: feature id %d held by both %q and %q",232 w.Slug, feature.ID, holder, feature.Title)233 }234 seen[feature.ID] = feature.Title235 switch collection.Kind {236 case KindPoint:237 counted.Point++238 case KindPath:239 counted.Path++240 default:241 counted.Area++242 }243 }244 }245 var ledgered Counts246 for _, account := range w.Ledger {247 if err := GateAccount(account); err != nil {248 return fmt.Errorf("world %s: %w", w.Slug, err)249 }250 if account.Origin {251 ledgered.Point += account.DonorFeatures.Point252 ledgered.Path += account.DonorFeatures.Path253 ledgered.Area += account.DonorFeatures.Area254 continue255 }256 ledgered.Point += account.Added257 }258 if counted.Point != ledgered.Point {259 return fmt.Errorf("world %s holds %d points but its ledger claims %d",260 w.Slug, counted.Point, ledgered.Point)261 }262 if counted.Path != ledgered.Path || counted.Area != ledgered.Area {263 return fmt.Errorf("world %s holds %d paths and %d areas but its ledger claims %d and %d",264 w.Slug, counted.Path, counted.Area, ledgered.Path, ledgered.Area)265 }266 return nil267}268269// OpenOrigin opens each world's own account: where its ground came from, and270// what it held when it got there.271//272// Composition opens one of these itself for a volume nobody enriched. A volume273// that goes through the queue has to open its accounts first, before anything274// is contributed, because the origin account says what the world arrived with275// -- and after an enricher has run, nothing can tell what that was.276//277// A world that already has an account is left alone: this is opening a ledger,278// not rewriting one.279func OpenOrigin(v *Volume) {280 for index := range v.Worlds {281 world := &v.Worlds[index]282 if len(world.Ledger) > 0 {283 continue284 }285 world.Ledger = []Account{{286 Source: v.Source.Label,287 Slug: v.Source.Name,288 Origin: true,289 DonorFeatures: Tally(world),290 }}291 }292}293294// Tally counts a world's features by kind.295func Tally(w *World) Counts {296 var out Counts297 for _, collection := range w.Collections {298 switch collection.Kind {299 case KindPoint:300 out.Point += len(collection.Features)301 case KindPath:302 out.Path += len(collection.Features)303 default:304 out.Area += len(collection.Features)305 }306 }307 return out308}