1// Package curation holds the enrich lane's editorial decisions as data.2//3// Two kinds of thing live here. The first is the order the enrichers run in,4// which the lane's contract requires to be declared rather than emergent: a5// queue in a reviewed file is a decision a reader can see, where a queue that6// falls out of a registration order is folklore. The second is the handful of7// numbers and per-key policies that decide what a contribution is allowed to8// conclude -- how near is near enough to be the same place, which attributes a9// serving reading takes from another when it has none of its own, and which10// collections a national evidence base is not allowed to make claims about.11//12// The file is curation.json, embedded, so a build carries its own curation and13// an enriched volume cannot depend on what happened to be on the operator's14// disk. Its schema is documented in docs/enrich.md; this package is the reader15// and the checker.16package curation1718import (19 _ "embed"20 "encoding/json"21 "fmt"2223 "github.com/FelineStateMachine/atlas/format/semconv"24)2526//go:embed curation.json27var embedded []byte2829// Schema is the curation file layout this package reads. It moves when an30// existing field's meaning breaks.31const Schema = 13233// Tables is the whole curated corpus, read. It satisfies the enrich lane's34// Curation interface.35type Tables struct {36 queue []string37 matchRadius float6438 separateRadius float6439 nearbyFloor float6440 donorFillsEmpty map[string]bool41 evidenceByVolume map[string][]string42}4344type wire struct {45 Schema int `json:"schema"`46 Queue struct {47 Order []string `json:"order"`48 } `json:"queue"`49 Merge struct {50 MatchRadiusPx float64 `json:"matchRadiusPx"`51 SeparateRadiusPx float64 `json:"separateRadiusPx"`52 NearbyFloorPx float64 `json:"nearbyFloorPx"`53 Attributes struct {54 DonorFillsEmpty []string `json:"donorFillsEmpty"`55 } `json:"attributes"`56 } `json:"merge"`57 National struct {58 EvidenceCollections map[string]json.RawMessage `json:"evidenceCollections"`59 } `json:"national"`60}6162// Load reads the embedded curation. It is the only way to get the real tables,63// so no caller can enrich against curation nobody reviewed.64func Load() (Tables, error) { return parse(embedded) }6566// LoadFrom reads curation from bytes, for a test that wants to state its own67// corpus rather than the real one.68func LoadFrom(data []byte) (Tables, error) { return parse(data) }6970func parse(data []byte) (Tables, error) {71 var raw wire72 if err := json.Unmarshal(data, &raw); err != nil {73 return Tables{}, fmt.Errorf("decode curation: %w", err)74 }75 if raw.Schema != Schema {76 return Tables{}, fmt.Errorf("curation schema %d, want %d", raw.Schema, Schema)77 }78 if len(raw.Queue.Order) == 0 {79 return Tables{}, fmt.Errorf("curation declares no enricher queue")80 }81 if raw.Merge.MatchRadiusPx <= 0 || raw.Merge.SeparateRadiusPx <= raw.Merge.MatchRadiusPx {82 return Tables{}, fmt.Errorf(83 "curation declares a match radius of %g and a separate radius of %g; "+84 "the second has to be the larger, or nothing is ever held back",85 raw.Merge.MatchRadiusPx, raw.Merge.SeparateRadiusPx)86 }87 if raw.Merge.NearbyFloorPx < 0 {88 return Tables{}, fmt.Errorf("curation declares a negative nearby floor")89 }90 out := Tables{91 queue: raw.Queue.Order,92 matchRadius: raw.Merge.MatchRadiusPx,93 separateRadius: raw.Merge.SeparateRadiusPx,94 nearbyFloor: raw.Merge.NearbyFloorPx,95 donorFillsEmpty: make(map[string]bool, len(raw.Merge.Attributes.DonorFillsEmpty)),96 evidenceByVolume: make(map[string][]string, len(raw.National.EvidenceCollections)),97 }98 for _, key := range raw.Merge.Attributes.DonorFillsEmpty {99 // The policy table speaks the conventions' vocabulary, so a key nobody100 // registered is a typo rather than a policy. The one deliberate101 // exception is the name a description travels under, which is102 // registered nowhere on purpose: no payload carries it.103 if _, known := semconv.EntityOf(key); !known && key != semconv.KeyNoteText {104 return Tables{}, fmt.Errorf(105 "curation gives %q an attribute policy, but the conventions registry does not know it", key)106 }107 out.donorFillsEmpty[key] = true108 }109 // The evidence table carries prose keys alongside its volumes, the way110 // every other section does, so a value that is not a list is commentary and111 // not a volume.112 for volume, value := range raw.National.EvidenceCollections {113 var titles []string114 if err := json.Unmarshal(value, &titles); err != nil {115 continue116 }117 out.evidenceByVolume[volume] = titles118 }119 return out, nil120}121122// Queue is the order the enrichers run in.123func (t Tables) Queue() []string { return t.queue }124125// MatchRadiusPx, SeparateRadiusPx and NearbyFloorPx are the resolution126// distances, in world pixels of the volume's own square.127func (t Tables) MatchRadiusPx() float64 { return t.matchRadius }128func (t Tables) SeparateRadiusPx() float64 { return t.separateRadius }129func (t Tables) NearbyFloorPx() float64 { return t.nearbyFloor }130131// DonorFillsEmpty reports whether a matched feature takes this attribute from132// its counterpart when it has none of its own.133func (t Tables) DonorFillsEmpty(key string) bool { return t.donorFillsEmpty[key] }134135// EvidenceCollections lists the collections a national evidence base136// contributed to a volume.137func (t Tables) EvidenceCollections(volume string) []string { return t.evidenceByVolume[volume] }