clone

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

feat: decide a merge attribute by attribute, and name sources one way

Dami committed d77d558
Which side of a matched pin wins used to be decided structurally, with
one exception hand-carved for descriptions. A policy table now says it
key by key: the serving side keeps what it has and takes what it lacks
-- words, true coordinates, a standard-icon declaration for a category
with no artwork of any kind -- and every take is ledgered under the
same vocabulary the payloads speak, audited by the merge gate against
the registry.

Category identity moves into the payloads too: where two sources are
known to spell one concept two ways, the shared name rides each
category as atlas.category.key, and the merge reads identity off the
attribute rather than a table of its own. Ledgers also carry the
source's canonical slug beside its display label, ending the quiet
mismatch where the archive said ign and the workbench said ign-wiki.
commit
d77d55823e6d568761ad5a14d139980fad365198
parent
5eb88b474ae0a43d33a521533160131e5f09cb84
author
Dami <dami.etoile@gmail.com>
3 files changed, +172 -34
Mtools/generate/bundles.go3+-
Mtools/generate/main.go15++-
Mtools/generate/merge.go188+++++++++++++++++++++++++-----
M tools/generate/bundles.go +2 -1
M tools/generate/main.go +14 -1
M tools/generate/merge.go +156 -32
@@ -21,6 +21,7 @@ import (
2121 "sort"2222 2323 "github.com/FelineStateMachine/atlas/internal/blend"24+ "github.com/FelineStateMachine/atlas/internal/semconv"2425 )2526 2627 // Resolution distances, in world pixels of an 8192-pixel square. A confirmed
@@ -35,10 +36,12 @@ const (
3536 nearbyFloorPx = 483637 )3738 38-// categoryEquivalents maps one source's category slugs onto another's where39-// the concepts are known to be the same thing. Slugs equal after40-// normalization pair automatically; everything else stays source-specific41-// rather than being guessed together.39+// categoryEquivalents names the shared concept where two sources spell one40+// category differently, per game. It is applied by speakConventions, which41+// writes the shared name onto each category as atlas.category.key -- so the42+// payloads themselves carry the merge identity, and the merge below reads43+// only the attribute. Slugs equal after normalization pair automatically;44+// everything else stays source-specific rather than being guessed together.4245 var categoryEquivalents = map[string]map[string]string{4346 "cyberpunk-2077": {4447 "ripper-doc": "ripperdoc",
@@ -51,32 +54,86 @@ var categoryEquivalents = map[string]map[string]string{
5154 },5255 }5356 57+// categoryKey is a category's merge identity: the declared shared name when58+// the payload carries one, its icon key otherwise -- which is today's59+// behavior, named.60+func categoryKey(icon string, attrs map[string]string) string {61+ if key := attrs[semconv.KeyCategoryKey]; key != "" {62+ return key63+ }64+ return icon65+}66+67+// attributeMergePolicy says, attribute by attribute, which side of a matched68+// pin wins. servingWins is the default for anything unlisted; donorFillsEmpty69+// is the enrichment rule the description pioneered -- the serving side keeps70+// what it has and takes only what it lacks. Every take is ledgered by key,71+// so the payload accounts for its own composition.72+type attrRule int73+74+const (75+ servingWins attrRule = iota76+ donorFillsEmpty77+)78+79+var attributeMergePolicy = map[string]attrRule{80+ semconv.KeyNoteText: donorFillsEmpty,81+ semconv.KeyGeoLat: donorFillsEmpty,82+ semconv.KeyGeoLon: donorFillsEmpty,83+ semconv.KeyIconStd: donorFillsEmpty,84+}85+5486 // mergedSource is one source's account of its merge, carried in the map's5587 // payload: the alignment it stood on, what became of every donor pin, and5688 // the ledger a later pass -- or a curious reader -- can audit the decisions5789 // by.5890 type mergedSource struct {5991 Source string `json:"source"`92+ // Slug is the source's canonical name, the one the workbench's registry93+ // speaks, so ledgers and plugin cards agree without translation.94+ Slug string `json:"slug,omitempty"`6095 // Origin marks the account of the source the map itself came from, so a6196 // single-source map still says where it is from and a composed map's6297 // unledgered pins have somewhere to answer to. DonorPins on an origin6398 // account is simply the map's own count at composition.64- Origin bool `json:"origin,omitempty"`65- DonorPins int `json:"donorPins"`66- Matched []mergedPair `json:"matched,omitempty"`67- Added int `json:"added"`68- Adopted []adoptedPin `json:"adopted,omitempty"`69- Held []heldPin `json:"held,omitempty"`70- Rejected []heldPin `json:"rejected,omitempty"`71- Alignment string `json:"alignment,omitempty"`99+ Origin bool `json:"origin,omitempty"`100+ DonorPins int `json:"donorPins"`101+ Matched []mergedPair `json:"matched,omitempty"`102+ Added int `json:"added"`103+ Adopted []adoptedPin `json:"adopted,omitempty"`104+ Held []heldPin `json:"held,omitempty"`105+ Rejected []heldPin `json:"rejected,omitempty"`106+ Enriched []categoryTake `json:"enrichedCategories,omitempty"`107+ Alignment string `json:"alignment,omitempty"`72108 }73109 74-// sourceDisplayLabel is how a crawler's name reads on a card or a ledger.75-func sourceDisplayLabel(source string) string {110+// categoryTake records one attribute a serving category took from the111+// donor's counterpart: which category, which key.112+type categoryTake struct {113+ Category string `json:"cat"`114+ Key string `json:"k"`115+}116+117+// canonicalSourceSlug maps what the archive records -- crawler tags, with118+// emptiness meaning the founding source -- onto the slugs the workbench119+// registry declares, which are the one vocabulary sources are named in.120+func canonicalSourceSlug(source string) string {76121 switch source {77122 case "", "mapgenie":78- return "MapGenie"123+ return "mapgenie"79124 case "ign":125+ return "ign-wiki"126+ }127+ return source128+}129+130+// sourceDisplayLabel is how a source's canonical slug reads on a card or a131+// ledger.132+func sourceDisplayLabel(source string) string {133+ switch canonicalSourceSlug(source) {134+ case "mapgenie":135+ return "MapGenie"136+ case "ign-wiki":80137 return "IGN Wiki"81138 case "piggyback":82139 return "Piggyback"
@@ -87,14 +144,17 @@ func sourceDisplayLabel(source string) string {
87144 }88145 89146 // mergedPair records one place both sources pin: the donor pin, the serving90-// pin it resolved to, and how far apart the alignment put them.147+// pin it resolved to, how far apart the alignment put them, and every148+// attribute the serving pin took from the donor, by key.91149 type mergedPair struct {92150 Donor int64 `json:"d"`93151 Winner int64 `json:"w"`94152 DistancePx int `json:"px"`95153 // Enriched marks a pair whose serving pin had nothing to say and took96- // the donor's description.97- Enriched bool `json:"e,omitempty"`154+ // the donor's description: derived from Took, kept for readers of the155+ // older spelling.156+ Enriched bool `json:"e,omitempty"`157+ Took []string `json:"took,omitempty"`98158 }99159 100160 // adoptedPin records a donor-only pin that joined one of the serving map's
@@ -301,7 +361,11 @@ func mergeMap(
301361 }302362 303363 sourceLabel := sourceLabelOf(donor, donorGame)304- merge := &mergedSource{Source: sourceLabel, Alignment: report.String()}364+ merge := &mergedSource{365+ Source: sourceLabel,366+ Slug: sourceSlugOf(donor),367+ Alignment: report.String(),368+ }305369 306370 // A nameless neighbour matters out to where the alignment's own noise307371 // reaches: two sources place the same shop apart by their residuals, so
@@ -311,23 +375,32 @@ func mergeMap(
311375 // What the serving map already holds, by name, token, and place.312376 index := indexWinner(winner, winnerGrid)313377 314- equivalents := categoryEquivalents[winnerGame.Slug]315-316378 // A donor pin that stays distinct joins the serving category its own317379 // category maps onto, and the ledger records the adoption; only concepts318380 // the serving map does not have at all keep their donor categories, so319381 // the source-named group holds nothing but what is truly source-specific.382+ // Categories meet under their declared merge identity -- the383+ // atlas.category.key their payloads carry, their icon key otherwise --384+ // so the equivalence curation lives in the payloads, not here.320385 var keptCategories []catalogCategory321386 for _, group := range donor.Groups {322387 for _, category := range group.Categories {323388 kept := category324389 kept.Locations = nil325- donorKey := category.Icon326- mappedKey := donorKey327- if equivalent, ok := equivalents[donorKey]; ok {328- mappedKey = equivalent329- }390+ mappedKey := categoryKey(category.Icon, category.Attrs)330391 adoptive := index.categories[mappedKey]392+ // Attribute-level resolution at the category: a serving category393+ // with no artwork of any kind takes the donor's standard-icon394+ // declaration, and the ledger says so.395+ if adoptive != nil && attributeMergePolicy[semconv.KeyIconStd] == donorFillsEmpty &&396+ adoptive.IconAsset == "" && adoptive.Attrs[semconv.KeyIconStd] == "" &&397+ category.Attrs[semconv.KeyIconStd] != "" {398+ adoptive.Attrs = withAttr(adoptive.Attrs, semconv.KeyIconStd,399+ category.Attrs[semconv.KeyIconStd])400+ merge.Enriched = append(merge.Enriched, categoryTake{401+ Category: mappedKey, Key: semconv.KeyIconStd,402+ })403+ }331404 for _, location := range category.Locations {332405 merge.DonorPins++333406 x, y := affine.Apply(
@@ -343,11 +416,27 @@ func mergeMap(
343416 Winner: outcome.match.ID,344417 DistancePx: int(outcome.distance + 0.5),345418 }346- // Field-level: a serving pin with nothing to say takes347- // the donor's words rather than silencing them.348- if outcome.match.Description == "" && location.Description != "" {419+ // Attribute-level: the policy table says, key by key,420+ // what a serving pin takes from its donor, and every421+ // take is ledgered. The description travels under its422+ // policy name, and a pin's true coordinates fill in the423+ // same way words do: only where the serving side has424+ // none of its own.425+ if attributeMergePolicy[semconv.KeyNoteText] == donorFillsEmpty &&426+ outcome.match.Description == "" && location.Description != "" {349427 outcome.match.Description = location.Description350428 pair.Enriched = true429+ pair.Took = append(pair.Took, semconv.KeyNoteText)430+ }431+ for _, key := range []string{semconv.KeyGeoLat, semconv.KeyGeoLon} {432+ if attributeMergePolicy[key] != donorFillsEmpty {433+ continue434+ }435+ if location.Attrs[key] == "" || outcome.match.Attrs[key] != "" {436+ continue437+ }438+ outcome.match.Attrs = withAttr(outcome.match.Attrs, key, location.Attrs[key])439+ pair.Took = append(pair.Took, key)351440 }352441 merge.Matched = append(merge.Matched, pair)353442 case pinHeld:
@@ -449,8 +538,9 @@ func indexWinner(winner *catalogMap, grid tileGrid) *winnerIndex {
449538 for groupIndex := range winner.Groups {450539 for categoryIndex := range winner.Groups[groupIndex].Categories {451540 category := &winner.Groups[groupIndex].Categories[categoryIndex]452- if _, held := index.categories[category.Icon]; !held {453- index.categories[category.Icon] = category541+ key := categoryKey(category.Icon, category.Attrs)542+ if _, held := index.categories[key]; !held {543+ index.categories[key] = category454544 }455545 for locationIndex := range category.Locations {456546 location := &category.Locations[locationIndex]
@@ -458,7 +548,7 @@ func indexWinner(winner *catalogMap, grid tileGrid) *winnerIndex {
458548 index.byName[name] = append(index.byName[name], len(index.pins))459549 index.pins = append(index.pins, placedPin{460550 location: location,461- category: category.Icon,551+ category: key,462552 x: projectX(location.Longitude, grid),463553 y: projectY(location.Latitude, grid),464554 tokens: tokensOf(name),
@@ -655,6 +745,18 @@ func sourceLabelOf(donor *catalogMap, donorGame *catalogGame) string {
655745 return donorGame.Title656746 }657747 748+// sourceSlugOf reads the canonical slug off the donor's origin account. A749+// map without one -- there should be none, every map opens its account at750+// composition -- simply contributes no slug rather than a guessed one.751+func sourceSlugOf(donor *catalogMap) string {752+ for _, account := range donor.Merged {753+ if account.Origin {754+ return account.Slug755+ }756+ }757+ return ""758+}759+658760 func slugifyLabel(label string) string {659761 out := make([]rune, 0, len(label))660762 for _, r := range label {
@@ -729,6 +831,28 @@ func mergeGate(merge *mergedSource, winner *catalogMap) error {
729831 pair.Winner, first, pair.Donor)730832 }731833 claimed[pair.Winner] = pair.Donor834+ // Every take answers to the policy table and the registry: a key835+ // nobody registered has no business in a ledger, and the older836+ // enriched flag must say exactly what the takes say.837+ tookNote := false838+ for _, key := range pair.Took {839+ if key == semconv.KeyNoteText {840+ tookNote = true841+ continue842+ }843+ if entity, known := semconv.EntityOf(key); !known || entity != semconv.EntityLocation {844+ return fmt.Errorf("pair %d took %q, which no pin may carry", pair.Donor, key)845+ }846+ }847+ if pair.Enriched != tookNote {848+ return fmt.Errorf("pair %d says enriched=%t but its takes say %t",849+ pair.Donor, pair.Enriched, tookNote)850+ }851+ }852+ for _, take := range merge.Enriched {853+ if entity, known := semconv.EntityOf(take.Key); !known || entity != semconv.EntityCategory {854+ return fmt.Errorf("category %q took %q, which no category may carry", take.Category, take.Key)855+ }732856 }733857 seen := make(map[int64]string)734858 counted := 0