The rules behind
every result.
A technical inventory of the app’s explicit data filters, API configuration and browser rules. Paths refer to the livemap-search repository.
Scope: project-defined rules. Pelias also has internal matching, parsing and scoring behavior; this is not an exhaustive specification of its upstream internals.
1. Data preparation
| Layer / rule | Current behavior | Implementation |
|---|---|---|
| DATARegional extraction | OSM is extracted using the configured region polygon and complete_ways. Overture downloads use the region bounding box. Swiss public transport export is nationwide. Cached inputs are reused. | scripts/20-download-and-extract.sh; regions/*.json / *.geojson |
| DATAOverture admission | Require Point geometry, ≥2 coordinates, primary name and stable ID. Reject operating status closed or closed_permanently. If the first address declares a country, it must match the region country; missing country is permitted. | scripts/30-transform-overture.py → convert_feature() |
| DATAOverture confidence & taxonomy | Confidence must be present and ≥0.8. Uncategorized records are rejected. Join taxonomy.hierarchy with “/”; an include/exclude matches the whole path or a path prefix ending at a segment boundary. Exclude wins. | regions/overture-filter.json; scripts/30-transform-overture.py → passes_filter() |
| DATAIncluded taxonomy branches | food_and_drink; arts_and_entertainment; cultural_and_historic; sports_and_recreation; lodging; shopping; geographic_entities; education/library; community_and_government/public_facility; travel_and_transportation/ground_transport_facility_or_service. | regions/overture-filter.json → include |
| DATAExcluded taxonomy branches | shopping/vehicle_dealer; shopping/warehouse_club_store; cultural_and_historic/religious_organization. | regions/overture-filter.json → exclude |
| DATAOverture category output | CSV category uses taxonomy.primary, then categories.primary, then basic_category. The hierarchy admission rule and the emitted category are separate checks. | scripts/30-transform-overture.py → primary_category() |
| DATAIce cream subset | Select existing filtered CSV records whose category_json contains ice_cream_shop. Reuse Overture IDs; attach first address and first website from raw records. Emit source icecream, layer venue. | scripts/build-demo-collections.py → icecream_features() |
| DATARetailer subset | Valora feed records must have format brezelkoenig and address country CH. Deduplicate accepted records by store ID. Replace leading “BKS ” in names with “Brezelkönig ”. Emit source brezelkoenig, layer venue, demo_kind sponsor. No additional closure-status filter is applied to this feed. | scripts/build-demo-collections.py → sponsor_features() |
| DATADemo record validity | Require a nonempty ID and trimmed name. Coordinates must be numeric and finite, with longitude 5.95–10.50 and latitude 45.80–47.82 inclusive. This is a bounding-box check, not a Swiss border polygon test. GID = source:venue:id. | scripts/build-demo-collections.py → point(), feature() |
| DATAPT admission | Require CH, VALIDATED, hasGeolocation=true, operatingPointWithTimetable=true, official name, SLOID, valid world-coordinate ranges, and at least one non-UNKNOWN transport mode. Deduplicate by SLOID. Long designation becomes an alias when different. | scripts/30-transform-pt-stops.py → convert_row(), main() |
| DATAPT popularity | max(mode weight) + 10 × (mode count − 1). TRAIN 100; METRO/TRAM 70; BOAT 60; RACK_RAILWAY/CABLE_RAILWAY 55; CABLE_CAR/CHAIRLIFT 40; ELEVATOR/BUS 30; unlisted modes 20. This populates the search record; final UI order has additional rules. | scripts/30-transform-pt-stops.py → MODE_WEIGHT, convert_row() |
| DATAOSM classification | Configured landmark predicates require names and cover selected tourism attractions, historic sites, observation towers, lighthouses and heritage. Venue predicates cover the listed named amenity/shop/transport/leisure and other place tags. The template is the complete predicate list. | templates/pelias.json → imports.openstreetmap.layers |
| DATAOther base imports | Who’s On First supplies Swiss places and postcodes. OSM-derived polylines supply streets. OpenAddresses runs only when ENABLE_OPENADDRESSES=true. Admin lookup is enabled; OSM blacklist path is /data/blacklist/osm.txt. | scripts/40-build-index.sh; templates/pelias.json → imports |
2. Index and API configuration
| Layer / rule | Current behavior | Implementation |
|---|---|---|
| APIRegistered source/layer targets | Automatic discovery is disabled. Explicit mappings: overture/venue, icecream/venue, brezelkoenig/venue, pt/stop, openstreetmap/{address,venue,street,landmark}. | templates/pelias.json → api.targets |
| APIRelevance boosts | Source weights: icecream 8, brezelkoenig 5, pt 2. Layer weight: landmark 3. These influence API relevance scores; they do not guarantee a visible slot. Frontend ranking below is separate. api.maxSize=100 permits the browser’s requested candidate sizes. | templates/pelias.json → api.customBoosts |
| APIServices and focus | Current main configuration uses libpostal, Placeholder and PIP. Default focus is rendered from the region config; browser queries send their own focus. The separate lean-search experiment is not this configuration. | templates/pelias.json → api.services, api.defaultParameters; scripts/10-render-config.py |
| DATADemo import lifecycle | The demo script renders config, regenerates JSON + CSV, supplies a temporary importer config containing only demo-collections.csv, runs CSV import, refreshes Elasticsearch and restarts API. Stable IDs allow upserts. This script does not delete disappeared source records. | scripts/45-import-demo.sh |
| DATAFull build boundary | The base full-build script imports OSM, polylines, WOF and the configured Overture/PT CSVs. Demo import is a separate step. A forced full rebuild drops the index; demo collections must then be imported again. | scripts/40-build-index.sh; scripts/45-import-demo.sh |
3. Query construction and candidate selection
| Layer / rule | Current behavior | Implementation |
|---|---|---|
| UIText normalization | NFKD decomposition → strip combining marks → lowercase → replace non-ASCII a–z/0–9 runs with spaces → trim. The base ranking uses toLocaleLowerCase; collection helpers use toLowerCase. | frontend/index.html → normalize(); frontend/demo-search.js → normalize() |
| UIExact category intent | The whole normalized query must equal an alias, or alias + space + a normalized locality present in demo metadata. Ice cream: ice cream, icecream, gelato, gelati, glace, glaces, eis, eiscreme. Sponsor: pretzel, pretzels, brezel, brezeln, brezelkonig, brezelkoenig. Other suffixes are not category intent. | frontend/demo-search.js → category() |
| UISearch center | Default is map center. For recognized category + metadata locality, use the coordinates of the first matching catalog record in that locality—not a municipal centroid. | frontend/index.html → searchPoint() |
| UIRadius and base request | Radius slider: 1–100 km, default 25 km. Normal /v1/search or /v1/autocomplete asks for size=100, focus.point.lat/lon, and boundary.circle.lat/lon/radius. Focus biases and boundary limits are distinct request parameters. | frontend/index.html → search(), #search-radius |
| UINamed collection request | A supplementary /v1/autocomplete requests enabled dedicated sources, size=100, the same focus and circle. Each query token must prefix some token in the returned name + locality; token order need not match. Query “brezelkoenig” is normalized further to “brezelkonig”. | frontend/index.html → request(); frontend/demo-search.js → relevant() |
| UICategory candidate selection | The JSON catalog supplies candidates for the selected collection. Keep all those at distance ≤ radius and sort nearest-first; there is no category-catalog count cap. Distance uses a local equirectangular approximation with 111,320 metres per degree and cosine of mean latitude. | frontend/demo-search.js → categoryCandidates(), distance() |
| APICategory record retrieval | Browser fetches all selected GIDs with /v1/place?ids=… in parallel batches of 40. Only returned indexed records render; missing indexed records do not appear. An empty candidate set skips the call. The demo avoids /nearby, whose running implementation caps the radius at 5 km. | frontend/index.html → request() category branch |
| UICategory retains ordinary matches | Category retrieval supplements the ordinary base results. A sponsor-category query additionally searches pretzel, brezel and bretzel through autocomplete, each with sources=overture,openstreetmap, layers=venue and size=100 plus the same spatial constraints. Filter the combined base/ordinary results whose normalized name contains “brezelkonig”, leaving the official feed to supply those branches. Other ordinary matches remain. Supplementary failures show “Collection search unavailable”. | frontend/index.html → request() |
| UIMetadata enrichment | Match catalog by GID, or source + stringified ID; merge metadata over API properties. JSON selects category IDs and enriches named results—it is not merely a styling file and is not a replacement for the index. | frontend/demo-search.js → enrich(); frontend/index.html → demoReady, request() |
| UIToggles | Enabled collections participate in supplementary queries. Dedicated-source results of a disabled kind are removed at merge. Toggles do not change API boosts. Ordinary OSM/Overture copies can remain, sponsor-category expansion suppresses generic Brezelkönig copies only while the sponsor collection is enabled and its query succeeds. | frontend/index.html → demoOptions(); frontend/demo-search.js → merge() |
4. Ranking and visible result order
| Layer / rule | Current behavior | Implementation |
|---|---|---|
| UIBase candidate viewport filter | Remove address and street features outside the current map bounds. Venue, landmark, stop and other layers do not receive this viewport filter. This applies in addition to the API circle. | frontend/index.html → rankFeatures() |
| UIBase candidate score | Compare normalized name to normalized query text before the first comma. Start at 1; add 1 for exact name; add another 0.5 for an exact address/street. For venue/landmark/stop multiply by 0.5^(distance / falloff). falloff = max(250m, half the map-center-to-northeast-corner distance). Ties retain input order. The browser score does not multiply the API confidence/score. | frontend/index.html → rankFeatures() |
| UISupplementary order | Enriched supplementary features sort by distance to the chosen search point using MapLibre distance. They are placed before base candidates for deduplication. | frontend/index.html → request(); frontend/demo-search.js → merge() |
| UICross-result deduplication | Keep first occurrence. Duplicate = same nonempty GID, or equal normalized names with distance strictly <100m. Different names remain distinct. Same-name adjacent branches can collapse under this heuristic. | frontend/demo-search.js → merge(), distance() |
| UISponsored placement budget | After matching and deduplication, keep only sponsored-source records within 1,000 metres (inclusive) of the map center captured for the search. Sort these by distance and admit at most three. This gate applies before exact-name promotion and across all Show more pages. Noneligible sponsored-source records are omitted, not converted into ordinary cards. Ordinary records and ice cream still use the main 1–100 km search radius. A specifically named ordinary business can rank above admitted sponsors. Category locality overrides do not move the sponsorship center. This models a local sponsored-placement budget; it is not billing or an auction. | frontend/demo-search.js → merge(), distance(); frontend/index.html → showFeatures() sponsorCenter |
| UIPromotion order | For non-category queries, first promote exact normalized name or name + locality matches (compare the query before its first comma). Then eligible sponsors, then ice cream, then ordinary results, preserving within-group order and including each feature once. Category queries skip exact-name promotion. No count cap in merge(). | frontend/demo-search.js → merge() |
| UIClient pagination | Render 12 loaded results initially. “Show more” increases the visible limit by 12 without a new API request; rerender cards and markers while preserving list scroll. Summary says visible count of loaded count, not total database matches. Ordinary and named requests each fetch at most 100 per query; there is no Elasticsearch cursor pagination or guarantee that every ordinary match is loaded. | frontend/index.html → showFeatures() |
| UILandmark treatment | An ordinary landmark gets a landmark badge. It receives “Top hit” styling only when it is the first visible feature. This style does not itself reorder results. | frontend/index.html → showFeatures() |
5. Interaction, disclosure and operational limits
| Layer / rule | Current behavior | Implementation |
|---|---|---|
| UIInput scheduling | Submit searches from 2 characters. Autocomplete starts at 3 characters after the selected 300–2000ms delay (default 800ms). Radius changes and user-originated map movement debounce 250ms; toggles run search immediately. Programmatic card-selection fly-to does not rerun the search. | frontend/index.html → search(), scheduleAutocomplete(), event handlers |
| UIRequest freshness | Starting a request aborts the prior AbortController and increments a sequence. Before display, reject responses whose sequence is no longer current. Abort errors are ignored; request failures update API status and summary. | frontend/index.html → request() |
| UIReverse path | Map click requests /v1/reverse with size=8. This path has no search text, so it bypasses collection enrichment/merge, named relevance checks and frontend text ranking. | frontend/index.html → map click handler, request(), showFeatures() |
| UICard behavior | demo_kind icecream/sponsor chooses themed card and pin. Sponsor cards always say “Sponsored · Demo” and use the local official SVG asset. Card click or Enter/Space moves map to zoom 17. Weekly-hours details stop propagation so expanding hours does not select the map location. Text fields use textContent. | frontend/demo-cards.js; frontend/demo-cards.css; frontend/index.html → showFeatures() |
| DATAHours and rights | Valora hours are regular Monday–Sunday spans only; special hours are not applied. Demo metadata records fetch time, source URLs and no-partnership disclosure. Public access does not establish an open license; review reuse rights before deployment. | scripts/build-demo-collections.py → weekly_hours(), metadata; frontend/demo-cards.js |
| OPSCurrent app vs experiment | This page describes the main local app and current checkout rules. The no-libpostal lean-search branch is a separate experiment. Local edits/imports do not deploy shared environments. | templates/pelias.json; scripts/45-import-demo.sh |
6. Proposal: move ranking rules behind the API
Not implemented. Today the browser builds the query fan-out, merges results, gates sponsors and orders the groups (sections 3–4). Every client (web, Android, iOS) would have to reimplement those rules. A thin search endpoint in front of Pelias could apply them once and return each result with the facts behind its position, so a client only decides how to draw it.
| Rule | Where it runs today | Proposed owner |
|---|---|---|
| SERVERCategory intent | Alias and locality matching in category(); the vocabulary ships with the page. | Server config. The response reports the recognized intent, such as {"category":"icecream","locality":"Zürich"}. |
| SERVERQuery fan-out | The browser sends the base search, the collection autocomplete, pretzel expansions and /place batches of 40. | The server issues these calls to Pelias in parallel. The client sends one request. |
| SERVERCatalog enrichment | demo-collections.json is downloaded and joined by GID. | The server joins it, or the importer writes it into the indexed record's addendum. The client never loads the catalog. |
| SERVERDeduplication & sponsor budget | merge() applies the GID / same name <100 m rule and the 3-within-1 km gate. | The server enforces both. A sponsor budget that runs in the client can be bypassed and cannot be audited or counted. |
| SERVERGroup order & score | Exact name → sponsors → ice cream → ordinary, plus rankFeatures() distance decay. | The server returns the final order, and each result carries its group, rank and score inputs. |
| CLIENTPresentation | Card kind, pin, badges, “Top hit”, pagination. | Stays in the client, driven by the returned hints. A client can collapse groups, hide a kind, or show sponsors differently without changing the server. |
| CLIENTViewport-only rules | Address/street viewport filter; falloff from map extent. | The client sends the viewport or bbox as a parameter, and the server applies the rule. Only drawing stays local. |
Response shape
Keep Pelias GeoJSON, so existing parsers still work. Add one namespaced object per feature, plus a summary on the collection:
{
"type": "FeatureCollection",
"livemap": {
"intent": {"category": "sponsor", "locality": null},
"center": [8.5417, 47.3769], "radius_km": 25,
"loaded": 64, "ruleset": "2026-10-01"
},
"features": [{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [8.5403, 47.3779]},
"properties": {
"gid": "brezelkoenig:venue:1234", "name": "Brezelkönig", "...": "...",
"livemap": {
"group": "sponsored", "rank": 1,
"display": {"kind": "sponsor", "badge": "Sponsored", "disclosure": true},
"reasons": ["sponsor_within_1km"],
"signals": {"distance_m": 140, "pelias_confidence": 0.9, "exact_name": false},
"sponsor": {"slot": 1, "slots_max": 3}
}
}
}]
}What the client decides
group and rank give a default order the client may ignore. A map-first surface might show only sponsored and exact. A list might fold icecream into a carousel. display.kind picks the card and pin. reasons and signals tell a debug UI or this page why a result is where it is.
Toggles become request parameters (for example collections=icecream,-sponsor), so the server can skip a source instead of fetching it and dropping it.
Why it is worth it
Web, Android and iOS rank the same way. Weights and vocabularies can change without an app release. Sponsored placement can be counted and audited. The client sends one request instead of four or more.
What it costs
One more service to own, and a network hop in front of Pelias. Its contract (livemap.* fields) must stay stable for released apps, so version it with ruleset. Pagination needs a cursor or a cached result set; it cannot stay a client-side slice.
Order of moves
1. Move demo-search.js logic unchanged behind one endpoint, and have the web page call it. 2. Return the livemap hints and drive cards from them. 3. Move vocabularies and weights into server config. 4. Point the native apps at the same endpoint.
frontend/apps/api, or as a small facade in this repo's Compose stack) is an open decision. Pelias stays the candidate generator either way. Its customBoosts still shape what the server gets back.Change a rule at the right layer
Change an API weight
Edit templates/pelias.json → api.customBoosts, then:
scripts/10-render-config.sh switzerland
bash -c 'source scripts/common.sh switzerland; compose restart api'No data import is needed for a score-only change. Browser placement remains separate from API relevance scoring.
Refresh demo data
Edit the collection builder if needed, then regenerate and import:
scripts/45-import-demo.sh switzerlandRequires the local index and existing regional Overture extracts. Upserts current IDs; does not purge absent IDs.
Change category discovery or display order
Edit frontend/demo-search.js and query assembly in frontend/index.html. Card design lives in frontend/demo-cards.js and frontend/demo-cards.css. Reload the browser; no index rebuild.
Change base-source admission
Edit the transform or regions/overture-filter.json, regenerate with scripts/20-download-and-extract.sh switzerland, then reimport affected records. An upsert cannot remove records newly excluded by a filter.
A deliberate full rebuild uses scripts/40-build-index.sh switzerland --force: it drops the current index. Run the separate demo import afterward. Do not use this for a weight or styling change.