which area contains which, read either way: outward from a street to the country, to say
"Rua Castro Alves, Embaré, Santos, São Paulo, Brasil", and downward from the country to its
states, cities and neighbourhoods, the way a directory is walked. the folder took
src/index/hierarchy.rs, src/database/admin_levels_hierarchy.rs, the tantivy index and the pass
that built it; src/index/ is gone with them.
admin_levelan area has any number of parents — a street crossing two neighbourhoods belongs to both, a
neighbourhood straddling two cities to both — so the row is an edge, (admin_level_id,
parent_id), and an area has one row per parent. that is a table of its own and not two columns of
admin_levels, which holds one row per area. the same test that kept the rtree inside
admin_level keeps this out of it:
admin_levels_rtree |
admin_levels_hierarchy |
|
|---|---|---|
| what it stores | a bounding box, recomputable in milliseconds | which area contains which, answered by sampled geometry |
| who reads it | only admin_level’s own coordinate query |
the tantivy index, the query path, the street merge, the cli’s optimize-* stages and the tui |
the tantivy document is one per path, not per admin level — a path, not the area, is the unit
of search and the unit of an answer, which is why the search index lives here and not in
admin_level.
the table stores nothing but the edge. a root has one row with a parent_id of NULL, so an area
that is resolved always has a row and roots() is a read of the index rather than a scan for
absence. the index admin_levels_hierarchy_search_by_parent covers both directions of the walk:
children_of(id) and roots() read it, for the tui that walks the tree like folders, parents_of(id) reads the primary key.
everything longer than one step is derived. paths::paths_of(id, edges) enumerates the paths of
an area up to the roots — most specific first and without the area itself — taking the parents in
id order and, under each parent, its own paths in the order it enumerates them. the position of a
path in that list is its ordinal, and it is stored nowhere: the index build and the query call
the same function over the same edges, so the ordinal in a search hit names the same path the
query rebuilds. the enumeration stops at MAX_PATHS, counting what it dropped, and a depth cap
ends a cycle instead of looping.
the query climbs with repository::ancestry_of, one statement per layer up to the roots, and the
index build reads the whole table once with load_all_edges.
the tree is sparse and not strictly ranked: a street inside no neighbourhood attaches straight to its city, and an area may sit inside another at the same level when that one is larger.
resolverrun answers “who contains whom” for every area the table does not know yet. it loads every area
below street level with its rings, centroid and area into memory, builds an in-memory rtree over
their boxes, then walks the levels in ascending order so that a parent is always finished before
its children look it up. each level runs in two phases: the geometry of the whole level in
parallel against the entries as they are, then a sequential pass, largest area first, that reduces
the candidates and records what sits above each area. a same-level parent is always strictly
larger, so that order is topological and nothing is read before it is written. streets never
contain anything, so they come last, in pages, against the same tree — on a file database up to
eight readers scan disjoint id ranges while the owning connection writes, and an in-memory
database is scanned on the calling thread, because a worker could not reopen it.
an area is asked about where it is, not where its centroid is: a polygon by a grid of points over its box kept to its interior, a line by its vertices and the middle of each segment. a street is a parent’s child as soon as one sample falls strictly inside it, so it joins every area it passes through; any other area has to put a tenth of its samples inside a more general candidate, or half of them inside a peer of its own level, or a sloppily drawn neighbourhood would swallow the one beside it. a point exactly on a border counts as neither in nor out: it promotes only a street, and only to an area more specific than everything the street actually entered, which is the street traced over the line two neighbourhoods share; two areas sharing a boundary are neighbours, not one inside the other.
a street folded from several ways (below) is asked one line at a time: what a line that runs along a border is promoted to depends on what that line entered, and asking the whole would let its neighbours change the answer, so resolving a folded street again writes the edges folding wrote.
what survives is every area the child sits in, at any level, minus the ones another survivor already hangs from — the street inside a neighbourhood hangs from the neighbourhood, not from its city as well, while the road that leaves its neighbourhood and runs on into the next city keeps both. an area nobody contains is a root.
the candidates are measured from the most specific outward and an area already above one that qualified is never measured at all, because the reduction would drop it: that is what keeps the ring of a country out of the pass. a ring longer than five hundred edges is indexed by latitude band when it is loaded, so a point test walks a few dozen edges instead of the whole boundary of a state.
isolatedan extract brings rows no address ever names: the neighbouring country whose boundary came with the
file and holds nothing, and the street the resolver found inside no area. isolated deletes both
after the resolver and before the street merge, so the fold and the two indexes work over the rows
that stay: it is geolite exec optimize-delete-isolated-admin-levels, which build and
geolite merge run between index-admin-levels-hierarchy and optimize-merge-admin-levels.
the rule names two levels and reads the edges as they are. a country without children is a row
of level 2 that is the parent_id of no edge; a street without parents is a row of level 12
with no edge but the root one. a child is a child whatever it holds, so a country whose states are
empty stays, and no other level is touched: a state without children and a neighbourhood without
parents are left alone. the rule runs once and not to a fixed point — a country that was the only
child of another goes, and the outer one is without children only for the next run. it has no floor
either: where every row is isolated the base ends empty, and the stage after it refuses.
the edges and the house numbers of a row leave with it, because both reference admin_levels(id)
and cascade on delete, and a row that goes is the parent of none that stays, so the hierarchy still
covers every row. the search index and the rtree are cleared before the first write, as the street
merge clears them; a run that finds nothing isolated touches neither.
street_mergea street mapped in several ways answers several times under one label, and its numbers sit on
whichever way they were linked to. street_merge folds the ways of one street into one row after
the resolver has said which areas each way is in and before the search index is built, because the
rule needs the areas and the index needs the rows: it is geolite exec optimize-merge-admin-levels,
which build and geolite merge run between optimize-delete-isolated-admin-levels and
index-addresses. no index stage folds on its own.
two ways of the same name are one street when they share an area, their post codes agree (a
missing code agrees with any, two different codes do not) and they touch or come within 20 m: the
end of one is within REACH_IN_METERS of the line of the other, which is what an avenue with a
median puts between the two halves of the street that crosses it. the rule runs to a fixed point over
the pieces as they grow, so two pieces that share an area only through what each has already
absorbed still join, which is what a second run would find: a second run finds nothing. a street
with no area never folds, since nothing says it is one street.
the row that survives is the one with the smallest id of the piece, and it keeps that way. its
geometry is always a multi-line holding the line of every way as the way has it, its own first and
the others in id order, and merged_way_ids names the way of each line by position, so the ways
whose rows are gone can still be told. a way the street already holds is told by its id, not by its
coordinates, and is not folded again: two ways drawn over the same points both stay, and folding
again what was folded changes nothing. its areas are the reduced union of the areas of its ways, which is what the
resolver answers on the folded geometry. a street across two neighbourhoods is one row with two
parents and answers once under each. the numbers of a way move to the survivor before its row goes,
because house_numbers.admin_level_id cascades on delete, and the edges of the ways that go leave
with them.
the search index and the rtree hold one entry per row, so the command clears both before its first write and says so; a run that finds nothing to fold touches neither. it needs the hierarchy to be complete, and it refuses otherwise.
search_indexone tantivy document per hierarchy row: the area’s own name with its post code in both forms
(01310-100 and 01310100), and the names of every ancestor concatenated, each of the two in
three field variants — folded (lower case, no diacritics), strict (as written) and lower
(diacritics kept). a preset’s abbreviations are expanded in both directions into the folded text,
so rua finds r. and back.
search runs three queries, each one only when the one before is empty. the strict one demands
every token exactly, in the name or in the ancestry, and wins when it finds anything — phrase order
and the strict and lower forms only re-rank that set, never widen it. the second one is the strict
one without demanding the words the caller names as optional, which the text service fills with
the words that read as a house number: 11 in euclides da cunha 11 gonzaga santos is a word no
document holds, so the documents covering every other word come back, and one that does hold the
number (25 in rua 25 de marco 100) ranks above the ones that do not; the phrase is then the
one the demanded tokens make. it is skipped when no word is optional or none is left to demand.
the loose one takes exact and fuzzy terms as optional clauses, which covers a typo, an extra word
or partial coverage; it ranks by bm25 alone, so a document repeating half of the query outranks
one covering most of it — which is why a house number must not be what sends a query there.
the score is the raw bm25 of whichever query found the document. last_admin_levels and the
region filter are Must clauses with boost 0.0: they restrict the document set without touching the
score. why exact and fuzzy carry separate
boosts, and why short tokens get one edit of tolerance, is written next to the boosts in the
source.
one document per path, so the two paths of a street crossing two neighbourhoods are two documents, and naming one of the neighbourhoods ranks its path first. a hit answers the area id and the ordinal of the path, which is what the query needs to rebuild the same path.
coverage(query_tokens, own, ancestors) is the similarity the text service reports: the share of
the query’s tokens found in a document’s text, the text rebuilt through the same pipeline the build
runs (the name with its post code in both forms, then tokenize), so the similarity and the index
agree token for token.
two documents with the same score rank by area id, then by ordinal, ascending. the tie-break is
the collector’s own sort key, read from the admin_level_id and ordinal fast fields inside
tantivy: the multi-threaded writer lays the documents out differently on every build, and the
scored collector prunes with block-wand and drops a document that merely ties the threshold, so a
sort after the fact would still see a different set per layout — the ids in the key are what make
the ranking a contract. load refuses an index missing either fast field: one built by an earlier
version reads as absent, and the cli asks for geolite exec index-addresses. the score in that
key is rounded to three decimals: bm25 separates two identical documents by an ulp, according to the
segment each one fell in, and the raw value would let that noise decide ahead of the ids.
build reads the names, post codes and levels through admin_level::repository::load_all_names,
so the index knows the table only through its owner. run is what the cli’s
geolite exec index-addresses calls: the build, with the row count reported around it.