1// Package compose turns an interchange document into a valid volume.2//3// It is the second half of the generate lane: a source has said what it has to4// say, and composition makes a .atlas file of it. That means normalizing to the5// format's unified model, speaking the semantic conventions on the volume's6// behalf and holding every attribute to the registry producer-strict, splitting7// what needs splitting, packing the three payload parts and the tile pyramids,8// stamping the result, and writing it into a registry so carefully that a reader9// scanning mid-write sees either no file or a whole one.10//11// Composition is single-source. What one source said travels here whole and12// unmixed; folding two sources' readings of one volume together is the enrich13// lane's work (docs/enrich.md).14//15// # Determinism16//17// The same document and the same tile set give the same bundle, byte for byte,18// on any machine -- the same stamp, and therefore the same file name. Nothing19// here reads a clock: a build's creation time is the newest capture time in its20// document, so a rebuild years later of an archive nobody touched writes the21// file that is already there. That is the whole reason a directory of these22// files can stand as a registry.23package compose2425import (26 "encoding/json"27 "fmt"28 "log/slog"29 "sort"3031 "github.com/FelineStateMachine/atlas/format/semconv"32 "github.com/FelineStateMachine/atlas/internal/generate/curation"33 "github.com/FelineStateMachine/atlas/internal/generate/doc"34 "github.com/FelineStateMachine/atlas/internal/generate/icons"35 "github.com/FelineStateMachine/atlas/internal/generate/tiles"36 "github.com/FelineStateMachine/atlas/internal/logging"37)3839// PolicyRevision orders builds of the same capture. The data has not moved40// between them, but what this lane makes of a capture has -- a merge rule, a41// kept field, a dropped collection -- and among equal captures a registry42// serves the highest revision. It is bumped when a policy change should43// supersede the builds already in every library.44//45// 1 first revisioned builds46// 2 merge resolution: subset names, adoption, one-to-one matches47// 3 origin provenance on every world; overlap merges across world slugs48// 4 semantic conventions: attributes ride every payload49// 5 standard icons resolved for collections that declare one50// 6 geometry declared: spheres say so, pins carry true coordinates51// 7 attribute-level merge resolution; ledgers name canonical source slugs52// 8 shape prose defers to the text payload; shapes mark hasText53// 9 format v3: one collections array of point, path, and area features54const PolicyRevision = 95556// Options is one composition.57type Options struct {58 // Document is the source's reading of the volume.59 Document doc.Document60 // Tiles is the derived tile set the volume's lenses are drawn from.61 Tiles *tiles.Set62 // Curation is the editorial data the composition consults.63 Curation curation.Tables64 // Ledger is a world's whole provenance, by world slug, where the caller has65 // one to hand. It is how the enrich lane's accounts reach a payload without66 // either lane importing the other: the accounts arrive already serialized67 // and composition writes them as they are. A world the caller says nothing68 // about gets the single origin account composition opens itself.69 Ledger map[string][]json.RawMessage70 // Revision is the build revision the manifest carries. Zero is this lane's71 // own PolicyRevision, which is what a plain single-source build writes; the72 // enrich lane hands its own, which is how an enriched build of one capture73 // deterministically outranks the plain build beside it.74 Revision int75 // BundleDir is the registry the finished bundle is installed into. Empty76 // composes and validates without writing anything, which is what a check77 // run wants.78 BundleDir string79 // Log is the run's event stream; nil is a logger that discards.80 Log *slog.Logger81}8283// Result is what a composition produced.84type Result struct {85 Volume string86 Stamp string87 // File is the name the build carries in a registry.88 File string89 // Path is where it was written, empty when nothing was written.90 Path string91 // Present reports that the build was already in the registry, so nothing92 // was written. It is the normal outcome of recomposing an unchanged93 // archive, and it is what makes a rebuild cheap.94 Present bool95 // Worlds, Tiles and Icons are what the bundle holds.96 Worlds, Tiles, Icons int97}9899// composedWorld is one world under composition: the document's world, resolved100// against a tile set and curation, on its way to a payload.101type composedWorld struct {102 ID int64103 Slug string104 Title string105 Parent string106 IconOutset string107 Center doc.Position108 CapturedAt string109 Grid *worldGrid110 Lenses []lens111 // Pyramids is the tile set entry behind each lens, in the same order.112 // A lens carries only what a payload says; a pyramid carries what a stamp113 // and a copy need.114 Pyramids []tiles.Pyramid115 Collections []composedCollection116 Attrs map[string]string117 Merged []json.RawMessage118}119120// composedCollection is a document collection with what composition knows added121// to it: the artwork it resolved, and the numbers a wire needs.122type composedCollection struct {123 ID int64124 Key string125 Title string126 Group string127 Kind string128 Icon string129 Color string130 IconColor string131 Visible bool132 Attrs map[string]string133 // IconAsset is the artwork's name inside the bundle, empty where the134 // document carried none.135 IconAsset string136 // IconPicture says the artwork is a picture drawn as-is rather than a glyph137 // the reader tints.138 IconPicture bool139 Features []composedFeature140}141142// composedFeature is a document feature with its place in a split world.143type composedFeature struct {144 doc.Feature145 // Shard is the layer of a split world this feature belongs to, zero when146 // the world is whole. It is composition's to decide: a source describes one147 // sheet, and whether that sheet is really several places is curation.148 Shard int64149}150151// Compose builds a volume and, where a registry is named, installs it.152func Compose(o Options) (Result, error) {153 log := o.Log154 if log == nil {155 log = slog.New(slog.DiscardHandler)156 }157 log = log.With(logging.Op("compose"), logging.Volume(o.Document.Volume.Slug))158159 if err := o.Document.Validate(); err != nil {160 return Result{}, fmt.Errorf("document: %w", err)161 }162 if o.Tiles == nil {163 return Result{}, fmt.Errorf("volume %s: no tile set to draw from", o.Document.Volume.Slug)164 }165 grid := worldGrid{166 SourceZoom: o.Curation.Window.SourceZoom,167 FirstTile: o.Curation.Window.FirstTile,168 }169170 var worlds []composedWorld171 for _, source := range o.Document.Worlds {172 pieces, err := resolveWorld(source, o, grid, log)173 if err != nil {174 return Result{}, fmt.Errorf("world %s: %w", source.Slug, err)175 }176 worlds = append(worlds, pieces...)177 }178 if len(worlds) == 0 {179 return Result{}, fmt.Errorf("volume %s: no world is ready to compose", o.Document.Volume.Slug)180 }181 orderWorlds(o.Curation, o.Document.Volume.Slug, worlds)182 artwork := attachArtwork(o.Document, worlds)183 if err := resolveStandardIcons(worlds, artwork); err != nil {184 return Result{}, fmt.Errorf("volume %s: %w", o.Document.Volume.Slug, err)185 }186 if err := speakConventions(o.Curation, o.Document.Volume.Slug, worlds); err != nil {187 return Result{}, fmt.Errorf("volume %s: %w", o.Document.Volume.Slug, err)188 }189 return write(o, worlds, artwork, log)190}191192// resolveStandardIcons makes good on every atlas.icon.std declaration: the named193// library glyph lands in the volume's artwork under its provenance-spelling194// name, and the collection wears it exactly the way it would wear artwork the195// source shipped itself.196//197// A source that has no artwork at all -- a gazetteer, a city's open data --198// names a glyph rather than inventing one, and naming is all it may do: which199// picture answers to the name is the library's business, and packing it is200// composition's. It runs after the document's own artwork has been attached and201// only where a source's own icon has not already won the slot, because a202// publisher's own drawing of a thing always beats a generic one.203//204// A declaration the library cannot answer fails the build. The promise was made205// in a translator; it is kept here or heard about, rather than reaching a206// reader as a collection with a hole where its marker should be.207func resolveStandardIcons(worlds []composedWorld, artwork map[string][]byte) error {208 for worldIndex := range worlds {209 world := &worlds[worldIndex]210 for index := range world.Collections {211 collection := &world.Collections[index]212 if collection.Kind != doc.KindPoint {213 continue214 }215 ref := collection.Attrs[semconv.KeyIconStd]216 if ref == "" || collection.IconAsset != "" {217 continue218 }219 data, asset, err := icons.Standard(ref)220 if err != nil {221 return fmt.Errorf("world %s collection %q: %w", world.Slug, collection.Title, err)222 }223 artwork[asset] = data224 collection.IconAsset = asset225 collection.IconPicture = false226 }227 }228 return nil229}230231// resolveWorld turns one document world into the worlds under composition it232// becomes: its lenses found in the tile set, its collections numbered, a curated233// sheet taken apart, the ground each piece covers measured, and every piece's234// own account of where it came from opened.235//236// It answers with a slice because one sheet may hold several places. All but one237// volume in the corpus answer with exactly one world, and the one that does not238// is declared in curation rather than detected.239func resolveWorld(source doc.World, o Options, shared worldGrid, log *slog.Logger) ([]composedWorld, error) {240 out := composedWorld{241 ID: source.ID,242 Slug: source.Slug,243 Title: source.Title,244 IconOutset: o.Curation.IconOutset(o.Document.Volume.Slug, source.Slug),245 Center: source.Center,246 CapturedAt: source.Capture.CapturedAt,247 Attrs: source.Attrs,248 }249 window := shared250 for index, declared := range source.Lenses {251 pyramid, ok := o.Tiles.Native(declared.TileSet)252 if !ok {253 return nil, fmt.Errorf("no pyramid is derived from tile set %s", declared.TileSet)254 }255 found := worldGrid{SourceZoom: pyramid.Window.SourceZoom, FirstTile: pyramid.Window.FirstTile}256 if found == (worldGrid{}) {257 // A tile set derived before pyramids carried their window: the258 // shared one is what everything in it was cut from.259 found = shared260 }261 // Every lens of a world is a picture of the same ground, so they agree262 // on the window it is cut from. One that did not would be a world drawn263 // in two places at once, and no feature could be placed on it.264 if index == 0 {265 window = found266 if found != shared {267 grid := found268 out.Grid = &grid269 }270 } else if found != window {271 return nil, fmt.Errorf(272 "tile set %s sits in a different window from %s",273 declared.TileSet, source.Lenses[0].TileSet)274 }275 out.Lenses = append(out.Lenses, lensOf(declared.Name, pyramid, o.Document.Volume.Slug))276 out.Pyramids = append(out.Pyramids, pyramid)277 // Another source's raster, resampled into this world, arrives as one278 // more way to see the same ground. It was rendered in this world's279 // window, so it passes the same agreement every native lens passes.280 for _, aligned := range o.Tiles.Aligned(declared.TileSet) {281 alignedWindow := worldGrid{282 SourceZoom: aligned.Window.SourceZoom,283 FirstTile: aligned.Window.FirstTile,284 }285 if alignedWindow != window {286 return nil, fmt.Errorf(287 "aligned pyramid %s was rendered in a different window", aligned.Name)288 }289 out.Lenses = append(out.Lenses, lensOf(aligned.LensName, aligned, o.Document.Volume.Slug))290 out.Pyramids = append(out.Pyramids, aligned)291 }292 }293294 collections, err := numberCollections(source)295 if err != nil {296 return nil, err297 }298 out.Collections = collections299300 grid := surfaceGrid{301 SourceZoom: window.SourceZoom,302 FirstTile: window.FirstTile,303 TileSize: o.Tiles.TileSize,304 Size: o.Tiles.Size,305 }306 // A sheet holding several separate places is taken apart here, where the307 // window it was cut from is finally known, and before the ground each piece308 // covers is measured: a piece's surface is its own, not the sheet's.309 mode := o.Curation.Shard(o.Document.Volume.Slug, source.Slug)310 pieces, err := splitWorld(out, mode, grid)311 if err != nil {312 return nil, fmt.Errorf("splitting into %s: %w", mode, err)313 }314 for index := range pieces {315 markSurfaces(&pieces[index], grid)316 // Every world opens its account with where it came from, split or317 // not: provenance is part of a world, not a side effect of318 // composition. A caller with a whole ledger to hand — the enrich319 // lane's ⊕ — supplies it by world slug, already serialized, and it320 // is written as it is.321 if ledger, held := o.Ledger[pieces[index].Slug]; held && len(ledger) > 0 {322 pieces[index].Merged = ledger323 } else {324 account, err := json.Marshal(origin{325 Source: o.Document.Source.Label,326 Slug: o.Document.Source.Name,327 Origin: true,328 DonorFeatures: tally(pieces[index].Collections),329 })330 if err != nil {331 return nil, fmt.Errorf("marshal origin account: %w", err)332 }333 pieces[index].Merged = []json.RawMessage{account}334 }335 log.Debug("world composed", logging.World(pieces[index].Slug),336 "lenses", len(pieces[index].Lenses),337 "collections", len(pieces[index].Collections))338 }339 return pieces, nil340}341342func lensOf(name string, p tiles.Pyramid, volume string) lens {343 return lens{344 Name: name,345 Tiles: tiles.LocalName(volume, p.Name),346 MinZoom: p.MinZoom,347 MaxZoom: p.MaxZoom,348 FullZoom: p.FullZoom,349 SourceZoom: p.SourceZoom,350 Formats: p.Formats,351 Bounds: p.Bounds,352 Interpolate: p.Interpolate,353 Background: p.Background,354 Coverage: p.Coverage,355 }356}357358// numberCollections gives every collection a numeric identity on the wire.359// A source that has native numbers passes them through; one that declares only360// a key gets a number derived from that key and the world it sits in, because361// a reader's hide and unfold sets are keyed by number and a key is not one.362//363// The derivation is FNV-1a over "<world id>:collection:<key>", masked into the364// positive range of a signed 32-bit integer, with zero -- which the wire reads365// as absence -- moved to one. A collision with a number the source already366// claimed fails the build rather than quietly renaming somebody's collection.367func numberCollections(source doc.World) ([]composedCollection, error) {368 used := make(map[int64]string, len(source.Collections))369 for _, collection := range source.Collections {370 if collection.ID != 0 {371 used[collection.ID] = collection.Title372 }373 }374 out := make([]composedCollection, 0, len(source.Collections))375 for _, collection := range source.Collections {376 id := collection.ID377 if id == 0 {378 if collection.Key == "" {379 return nil, fmt.Errorf("collection %q carries neither an id nor a key", collection.Title)380 }381 claimed, err := claimID(source.ID, collection.Key, used)382 if err != nil {383 return nil, err384 }385 id = claimed386 }387 features := make([]composedFeature, 0, len(collection.Features))388 for _, feature := range collection.Features {389 features = append(features, composedFeature{Feature: feature})390 }391 out = append(out, composedCollection{392 ID: id,393 Key: collection.Key,394 Title: collection.Title,395 Group: collection.Group,396 Kind: collection.Kind,397 Icon: collection.Icon,398 Color: collection.Color,399 IconColor: collection.IconColor,400 Visible: collection.Visible,401 Attrs: collection.Attrs,402 Features: features,403 })404 }405 return out, nil406}407408// orderWorlds puts a volume's primary world first and keeps a split sheet's409// pieces with the sheet they came from.410func orderWorlds(tables curation.Tables, volume string, worlds []composedWorld) {411 order := make(map[string]int)412 for index, slug := range tables.PreferredWorlds(volume) {413 order[slug] = index414 }415 // A version-history volume reads its date titles backward: the newest416 // capture opens, and the past waits one click below it.417 before := func(a, b string) bool { return a < b }418 if tables.NewestFirst(volume) {419 before = func(a, b string) bool { return a > b }420 }421 titles := make(map[string]string, len(worlds))422 for _, world := range worlds {423 titles[world.Slug] = world.Title424 }425 // Worlds sort as families: a piece carries its sheet's position and follows426 // it, so a split sheet stays together in the picker.427 family := func(w composedWorld) (string, string) {428 if w.Parent == "" {429 return w.Slug, w.Title430 }431 return w.Parent, titles[w.Parent]432 }433 sort.SliceStable(worlds, func(i, j int) bool {434 leftSlug, leftTitle := family(worlds[i])435 rightSlug, rightTitle := family(worlds[j])436 left, leftPreferred := order[leftSlug]437 right, rightPreferred := order[rightSlug]438 if leftPreferred != rightPreferred {439 return leftPreferred440 }441 if leftPreferred && left != right {442 return left < right443 }444 if leftTitle != rightTitle {445 return before(leftTitle, rightTitle)446 }447 if (worlds[i].Parent == "") != (worlds[j].Parent == "") {448 return worlds[i].Parent == ""449 }450 return before(worlds[i].Title, worlds[j].Title)451 })452}453454// attachArtwork resolves every point collection's artwork against the455// document's icon set, and reports the assets the bundle will carry. Artwork456// nothing names is left behind: a source may hand over a whole archived icon457// set, and a bundle carries what is used.458func attachArtwork(d doc.Document, worlds []composedWorld) map[string][]byte {459 byKey := d.IconByKey()460 out := make(map[string][]byte)461 for worldIndex := range worlds {462 collections := worlds[worldIndex].Collections463 for index := range collections {464 collection := &collections[index]465 if collection.Kind != doc.KindPoint || collection.Icon == "" {466 continue467 }468 icon, held := byKey[collection.Icon]469 if !held {470 continue471 }472 out[icon.File] = icon.Data473 collection.IconAsset = icon.File474 collection.IconPicture = isPicture(icon.File)475 }476 }477 return out478}479480// isPicture reads the one thing an asset's name decides: whether the artwork is481// a glyph a reader may tint, or a picture that must be drawn as it is.482func isPicture(file string) bool {483 return len(file) >= 4 && file[len(file)-4:] == ".png"484}