clone

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

feat/atlas-next-horizon
atlas / internal / enrich / enrich.go
enrich.go 10.6K
1// Package enrich makes a volume richer without making it something else.2//3// # The contract4//5// A volume goes in and a new build of the same volume comes out: same slug, new6// stamp. Nothing here mutates a published bundle -- the registry model is a7// library of immutable files and a fold over them, so publication is a new file8// landing beside the old one. Nothing here removes or rewrites what a source9// said. Every contribution is written down in a ledger the build gate audits.10// And no change means no build: an enrichment that finds nothing to say leaves11// the library exactly as it was.12//13// # The seam14//15// The enrich lane and the generate lane never import each other (issue #516// §3.2). The composed multi-source result is generate ⊕ enrich, and the ⊕ is17// performed by the one binary that holds both: `atlas enrich` translates18// archived captures into interchange documents with the generate lane, adapts19// them into the [Volume] model this package owns, runs the ordered queue,20// applies the contributions, adapts the result back into a document, and hands21// it to composition. Neither lane learns about the other; what travels between22// them is data.23//24// That is why this package has its own volume model rather than taking a25// pointer to the generate lane's document. The model carries the same26// information -- worlds, lenses, collections, features, attributes, provenance27// -- because both are shaped by what the format needs, and the adaptation is a28// mechanical copy that a round-trip test holds to identity.29//30// # The ordered queue31//32// Enrichers run in an order declared in curation data (internal/enrich/curation),33// never in an order that emerges from a map iteration or a registration side34// effect. Ordering is a real decision from the first enricher onward: standard35// icons resolve after a merge so that merged-in collections resolve too, and a36// lens attaches to a world after anything that might have added that world.37//38// # The evidence ethic39//40// Every enricher in this lane is held to one rule: silence over plausibility.41// An enricher that cannot be sure says nothing and, where the doubt is about a42// particular thing, writes down what it was unsure about. A held feature with43// its reason recorded costs a curiosity; a wrong claim costs a place.44package enrich4546import (47 "fmt"48 "log/slog"49 "time"5051 "github.com/FelineStateMachine/atlas/format/semconv"52 "github.com/FelineStateMachine/atlas/internal/logging"53)5455// An Enricher contributes to a volume under composition. It declares the56// semantic-conventions keys it may write, which are checked producer-strict; it57// sees the volume and whatever context the run supplies; and it returns58// additions. Every contribution is ledgered.59type Enricher interface {60 // Name is how curation orders it and how a log line names it.61 Name() string62 // Declares lists the attribute keys this enricher may write. An attribute63 // it writes that is not declared here fails the build, so what an enricher64 // can say is visible without reading it.65 Declares() []string66 // Enrich reads the volume and says what it has to add. It must not modify67 // the volume: the driver applies contributions, so every change is one that68 // went through the ledger and the gate.69 Enrich(v *Volume, ctx Context) (Contribution, error)70}7172// Context is everything an enricher may consult beyond the volume itself.73//74// Every field is data the caller supplies, which is what keeps this lane75// testable and offline: an enricher never opens a file, never reaches for the76// network, and never learns which source anything came from except through a77// ledger it is handed.78type Context struct {79 // Donors are other readings of the same volume -- other sources' documents,80 // adapted -- for the enricher that folds readings together.81 Donors []*Volume82 // Evidence is the archived material an enricher re-runs its judgement83 // against, so a policy change replays over the capture instead of over the84 // network. Nil is an evidence base with nothing in it.85 Evidence Evidence86 // Lenses offers the pictures derived for this volume's worlds.87 Lenses LensOffers88 // Curation is the lane's editorial data.89 Curation Curation90 // Log is the run's event stream; nil is a logger that discards.91 Log *slog.Logger92}9394// Evidence is the archived material an enricher reads. It is an interface95// rather than a directory because the evidence base travels in the capture and96// a test's evidence base travels in the test.97type Evidence interface {98 // Open returns the named evidence, and false where the base does not carry99 // it. Absent evidence is not an error: an enricher whose evidence is not100 // there says nothing, which is the ethic.101 Open(name string) ([]byte, bool, error)102}103104// LensOffers reports the pictures derived for one world but not yet attached to105// it: the tile pyramids something rendered, with the stamps that say how.106type LensOffers interface {107 Offers(world string) []Lens108}109110// Curation is the enrich lane's editorial data, as the driver and the enrichers111// read it. The concrete tables live in internal/enrich/curation; this interface112// is what keeps a test from having to state the whole corpus to exercise one113// rule.114type Curation interface {115 // Queue is the order the enrichers run in.116 Queue() []string117 // MatchRadiusPx, SeparateRadiusPx and NearbyFloorPx are the resolution118 // distances, in world pixels of the volume's own square.119 MatchRadiusPx() float64120 SeparateRadiusPx() float64121 NearbyFloorPx() float64122 // DonorFillsEmpty reports whether a matched feature takes this attribute123 // from its counterpart when it has none of its own. Everything else is124 // serving-wins.125 DonorFillsEmpty(key string) bool126 // EvidenceCollections lists the collections a national evidence base127 // contributed to a volume, which are the ones it may not then make claims128 // about.129 EvidenceCollections(volume string) []string130}131132// Result is what one run of the queue did.133type Result struct {134 // Contributions is every enricher's offering, in queue order, including the135 // empty ones: a run says what each enricher had to say, and "nothing" is an136 // answer.137 Contributions []Contribution138 // Changed reports whether anything at all was contributed. False is the139 // no-change-no-build case, and the caller writes nothing.140 Changed bool141}142143// Run drives the queue over one volume.144//145// Each enricher sees the volume as the ones before it left it, which is what146// makes the order meaningful: standard icons resolve for collections a merge147// added, and a lens attaches to a world a merge contributed. Each contribution148// is checked against what the enricher declared, applied, and ledgered, and the149// world's whole ledger is audited at the end.150//151// The volume is modified in place. A caller that wants the original back keeps152// its own copy -- which is what the enrich subcommand does, so that a run that153// gates can say what it would have written.154func Run(v *Volume, queue []Enricher, ctx Context) (Result, error) {155 log := ctx.Log156 if log == nil {157 log = slog.New(slog.DiscardHandler)158 }159 log = log.With(logging.Op("enrich"), logging.Volume(v.Slug))160161 // Every world's own account is opened before anything is contributed: the162 // origin account says what the world arrived with, and once an enricher has163 // run there is no way to find that out again.164 OpenOrigin(v)165166 var out Result167 for _, enricher := range queue {168 started := time.Now()169 contribution, err := enricher.Enrich(v, ctx)170 if err != nil {171 return Result{}, fmt.Errorf("enricher %s: %w", enricher.Name(), err)172 }173 contribution.Contribution = ContributionDoc174 contribution.Version = ContributionVersion175 if contribution.Enricher == "" {176 contribution.Enricher = enricher.Name()177 }178 if contribution.Volume == "" {179 contribution.Volume = v.Slug180 }181 if err := declared(enricher, contribution); err != nil {182 return Result{}, fmt.Errorf("enricher %s: %w", enricher.Name(), err)183 }184 if contribution.Empty() {185 log.Debug("enricher had nothing to add", logging.Enricher(enricher.Name()),186 logging.Dur(time.Since(started)))187 out.Contributions = append(out.Contributions, contribution)188 continue189 }190 if err := Apply(v, contribution); err != nil {191 return Result{}, err192 }193 digest, err := contribution.Digest()194 if err != nil {195 return Result{}, err196 }197 out.Contributions = append(out.Contributions, contribution)198 out.Changed = true199 log.Info("enriched", logging.Enricher(enricher.Name()),200 "ops", len(contribution.Ops), "contribution", digest[:12],201 logging.Dur(time.Since(started)))202 }203 for index := range v.Worlds {204 if err := GateWorld(&v.Worlds[index]); err != nil {205 return Result{}, err206 }207 }208 return out, nil209}210211// declared holds an enricher to what it said it would write. An attribute key212// outside the declaration fails the build: what an enricher can say about a213// volume is part of its contract, not an emergent property of its code.214func declared(e Enricher, c Contribution) error {215 allowed := make(map[string]bool, len(e.Declares()))216 for _, key := range e.Declares() {217 if _, known := semconv.EntityOf(key); !known {218 return fmt.Errorf("declares %q, which the conventions registry does not know", key)219 }220 allowed[key] = true221 }222 for _, op := range c.Ops {223 if op.Kind != OpSetAttr {224 continue225 }226 if !allowed[op.Key] {227 return fmt.Errorf("writes %q, which it does not declare", op.Key)228 }229 }230 return nil231}232233// Queue resolves the declared order against the enrichers a binary offers.234//235// It is strict both ways. A name curation declares that nothing implements is a236// build failure, because the queue is the statement of what runs. An enricher237// the binary offers that curation does not name is also a failure, because an238// enricher that runs in an order nobody wrote down is folklore, which is the239// thing the ordered queue exists to prevent.240func Queue(order []string, offered []Enricher) ([]Enricher, error) {241 available := make(map[string]Enricher, len(offered))242 for _, enricher := range offered {243 if _, twice := available[enricher.Name()]; twice {244 return nil, fmt.Errorf("two enrichers are called %s", enricher.Name())245 }246 available[enricher.Name()] = enricher247 }248 queue := make([]Enricher, 0, len(order))249 named := make(map[string]bool, len(order))250 for _, name := range order {251 enricher, held := available[name]252 if !held {253 return nil, fmt.Errorf("curation queues %q, which no enricher answers to", name)254 }255 if named[name] {256 return nil, fmt.Errorf("curation queues %q twice", name)257 }258 named[name] = true259 queue = append(queue, enricher)260 }261 for _, enricher := range offered {262 if !named[enricher.Name()] {263 return nil, fmt.Errorf("enricher %s runs in no declared order; "+264 "add it to the curated queue or stop offering it", enricher.Name())265 }266 }267 return queue, nil268}