Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion docs/docs/administration/services-administration.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,13 +210,23 @@ data workflow is documented in [Preparing data](../install/preparing-data.md).
### OSM code and alias search index

The data workflow also operates the local OSM code/alias/acronym index used by
consumer autocomplete. It shows the source region and fingerprint, current
consumer autocomplete. It shows the source region, current
build stage, place and term counts, publication epoch and time, whether a newer
PBF has made the index stale, and the last error. Building is explicit: select
the downloaded region and confirm the operation, or run
`openmapx data search-index build [region]`. A new PBF never triggers a
country- or planet-scale rebuild during API boot.

A ready index covers the documented aliases/codes/acronyms; it does not establish
ordinary business-name/address coverage. For an investigation, record the
authenticated `/api/data-manager/search-index/status` response's
`sourceFingerprint` and `epoch` as well as the Overture status response's release
and region. The search card's summary does not display the source fingerprint.
Keep unavailable generations unknown and do not rebuild or flush shared caches
to diagnose a query. See the
[business retrieval investigation](../developer/business-name-address-retrieval.md)
for the distinction between source presence and searchable candidates.

The job requires PostGIS and Osmium Tool. It streams records in bounded batches
into `osm_search__staging`, builds exact/prefix and geographic indexes, validates
counts and referential integrity, then swaps schemas in one database
Expand Down
167 changes: 167 additions & 0 deletions docs/docs/developer/business-name-address-retrieval.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions docs/docs/developer/discovery-evaluation.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ connects existing semantic search fixtures, conflation guards, Overture gates,
place-card and selected-sheet contracts and navigation replays. It does not turn synthetic tests
into evidence of current regional coverage or installed-device readiness.

For business-name/address retrieval, see the
[Berlin EDEKA investigation](business-name-address-retrieval.md). It compares
independent raw, adapted, combined, ranked and live UI evidence and uses stable
source identities. The pilot's label/radius score alone cannot verify a nearby
same-brand tenant or resolve a conflicting returned address.

## Run and compare

From the repository root:
Expand Down
1 change: 1 addition & 0 deletions knip.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"workspaces": {
".": {
"entry": [
"scripts/discovery-eval/business-name-address/replay.ts",
"scripts/check-dockerfile-workspace-sync.ts",
"scripts/check-credential-keys.ts",
"scripts/check-feed-ids.ts",
Expand Down
57 changes: 57 additions & 0 deletions scripts/discovery-eval/business-name-address/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Business name/address regression corpus

Reusable inputs from [#430](https://github.com/OpenMapX/openmapx/issues/430).
See the [findings and follow-up](../../../docs/docs/developer/business-name-address-retrieval.md).
This directory implements no search provider or ingestion process.

- `queries-v1.json`: 13 frozen queries, expected identities/negatives and budgets.
Alexanderstraße remains unresolved, outside exact-business recall.
- `responses-v1.json`: 52 distinct selected raw MapTiler features and adapted rows,
reused by ID across queries. Fixed inputs captured 2026-10-07; neither projection
is a full payload or a claim about today's provider/deployment.
- `sources-v1.json`: compact reviewed OSM/Overture identity/provenance records.
A shared mall address or brand is not a verified business cross-link.
- `evidence.test.ts`: real-adapter identity/order parity and specialized OSM
term-extractor behavior. Tests do not freeze historical ranking or UI state.
- `replay.ts`: current-checkout place-only combination/ranking/Enter comparison
over fixed inputs. It reports its own revision/dirty state. The historical
aggregate was empty/partial; absence from it proves no source gap. Shortcuts,
recents and actual UI observations are separate evidence.

```sh
pnpm exec vitest run scripts/discovery-eval/business-name-address/evidence.test.ts
pnpm exec tsc -p scripts/discovery-eval/business-name-address/tsconfig.json
pnpm -C packages/cli exec tsx ../../scripts/discovery-eval/business-name-address/replay.ts \
--out /tmp/business-retrieval-replay.json
```

## External captures and recapture

The [dated capture archive](https://gist.github.com/Medformatik/c36d8b8f44c88971277659512a533a51/8b33cd9238ba743f54f3235f2831470cf3bb5972) preserves historical request parameters,
response hashes/timings, full selected per-query lists, source acquisition details,
UI observations, status limitations and the original report. `checksums.json`
binds its exact bytes. These remain audit records rather than CI expectations.
The report links the reviewed 2026-10-08 lexical probe, its disposable container
command, measured costs and remaining adoption gates. Full raw operator
files and execution logs were temporary and are not a durable public artifact.

For recapture, use query file order. Fetch raw MapTiler and public adapter/aggregate
layers independently. Match the adapter's rounded `[13.42,52.52]` upstream anchor;
EN, zoom 15, MapTiler limit 6/autocomplete true/types including POI, aggregate
limit 8. Use an operator-owned key; never print or store it. Capture timestamps,
parameters, status and byte hashes before projection. Record remote deployment,
provider/generation and cache conditions separately, unknown when unavailable.
Use isolated caches for cold/warm claims. Freeze a new version for changed
judgments; do not replace historical fixtures with live responses.

## Source credits and rights

© MapTiler, © OpenStreetMap contributors. MapTiler search results remain subject
to [MapTiler Cloud terms](https://www.maptiler.com/terms/cloud/), including export
permission in section 6.4 and database attribution in section 6.3. OSM: ©
OpenStreetMap contributors, [ODbL-1.0](https://www.openstreetmap.org/copyright).
Overture/Meta: [CDLA-Permissive-2.0](https://cdla.dev/permissive-2-0/). Foursquare:
[Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0), copyright Foursquare Labs.
Selected source records retain applicable dataset/license IDs. External samples
are not relicensed as application code; official retailer pages establish
identity facts, not permission to redistribute their pages or photos.
63 changes: 63 additions & 0 deletions scripts/discovery-eval/business-name-address/evidence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { readFileSync } from "node:fs";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
maptilerGeocodingService,
setMaptilerApiKey,
} from "../../../integrations/geocoding-maptiler/provider.js";
import { extractTerms } from "../../../services/data-manager/src/jobs/search-index/terms.js";

function read(name: string) {
return JSON.parse(readFileSync(new URL(name, import.meta.url), "utf8"));
}
const queries = read("queries-v1.json");
const responses = read("responses-v1.json");
const sources = read("sources-v1.json");

afterEach(() => {
setMaptilerApiKey(undefined);
vi.unstubAllGlobals();
});

describe("business name/address adapter regression corpus", () => {
for (const entry of responses.cases) {
it(`${entry.caseId}: preserves every captured candidate and its displayed identity`, async () => {
const query = queries.cases.find((c: { id: string }) => c.id === entry.caseId);
const features = entry.rawIds.map((id: string) => {
const feature = responses.features.find((f: { raw: { id: string } }) => f.raw.id === id);
if (!feature) throw new Error("Missing regression candidate");
return feature;
});
const fetch = vi
.fn()
.mockResolvedValue(
Response.json({ features: features.map((f: { raw: unknown }) => f.raw) }),
);
vi.stubGlobal("fetch", fetch);
setMaptilerApiKey("fixture-only");
const adapted = await maptilerGeocodingService.autocomplete(query.query, queries.language, {
proximity: queries.upstreamProximity,
zoom: queries.zoom,
});
const fields = (r: {
id: string;
label: string;
sublabel?: string;
coordinates?: readonly number[];
type: string;
rawCategory?: string;
ids?: unknown;
}) => [r.id, r.label, r.sublabel, r.coordinates, r.type, r.rawCategory, r.ids];
expect(adapted.map(fields)).toEqual(
features.map((f: { adapted: Parameters<typeof fields>[0] }) => fields(f.adapted)),
);
expect(fetch).toHaveBeenCalledTimes(1);
});
}

it("keeps ordinary business names outside the specialized code/alias index", () => {
for (const id of ["way/1204762785", "node/322490364", "node/348000444", "node/9154787137"]) {
const record = sources.osm.records.find((r: { id: string }) => r.id === id);
expect(extractTerms(record.tags)).toEqual([]);
}
});
});
83 changes: 83 additions & 0 deletions scripts/discovery-eval/business-name-address/queries-v1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
{
"version": 1,
"baseline": "3c80939d26a09f131b16d849895255436b5e5798",
"region": "Berlin ALEXA and nearby branch controls",
"language": "en",
"center": [13.416, 52.5194],
"upstreamProximity": [13.42, 52.52],
"zoom": 15,
"budgets": {
"maxRank": 3,
"maxWrongBranchClaims": 0,
"coldMs": 5000,
"warmMs": 2000
},
"cases": [
{
"id": "moch-name",
"query": "EDEKA Moch",
"target": "moch"
},
{
"id": "moch-address",
"query": "EDEKA Moch Grunerstraße 20",
"target": "moch"
},
{
"id": "edeka-address",
"query": "EDEKA Grunerstraße 20 Berlin",
"target": "moch"
},
{
"id": "edeka-alexa",
"query": "EDEKA Alexa",
"target": "moch"
},
{
"id": "edeka-gruner",
"query": "EDEKA Grunerstraße",
"target": "moch"
},
{
"id": "edeka-alexander",
"query": "EDEKA Alexanderstraße 25 Berlin",
"target": null,
"judgment": "Returned-address control; real-world branch identity unresolved, excluded from exact-entity denominator"
},
{
"id": "edeka-brand",
"query": "EDEKA",
"target": null
},
{
"id": "media-alexa",
"query": "MediaMarkt Alexa",
"target": "media"
},
{
"id": "media-address",
"query": "MediaMarkt Grunerstraße 20 Berlin",
"target": "media"
},
{
"id": "rewe-address",
"query": "REWE Invalidenstraße 158 Berlin",
"target": "rewe"
},
{
"id": "wrong-address",
"query": "EDEKA Moch Grunerstraße 999 Berlin",
"target": "negative"
},
{
"id": "invented-name",
"query": "Zzqxv430 Testladen Grunerstraße 20 Berlin",
"target": "negative"
},
{
"id": "schaaf-address",
"query": "EDEKA Schaaf Schillingstraße 2 Berlin",
"target": "schaaf"
}
]
}
65 changes: 65 additions & 0 deletions scripts/discovery-eval/business-name-address/replay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/** Offline diagnostic replay. It never calls providers or changes production data. */
import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { getQueryVariants } from "../../../integrations/geocoding/query-expansion.js";
import type { AutocompleteResult } from "../../../packages/core/src/types/geocoding.js";
import {
enterAction,
rankAutocompleteRows,
} from "../../../packages/core/src/utils/suggestionRanking.js";

interface QuerySet {
baseline: string;
center: [number, number];
zoom: number;
cases: Array<{ id: string; query: string }>;
}
interface Responses {
features: Array<{ raw: { id: string }; adapted: AutocompleteResult }>;
cases: Array<{
caseId: string;
rawIds: string[];
}>;
}
function read<T>(name: string): T {
return JSON.parse(readFileSync(new URL(name, import.meta.url), "utf8")) as T;
}
const queries = read<QuerySet>("queries-v1.json");
const responses = read<Responses>("responses-v1.json");
const revision = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
const report = {
captureBaseline: queries.baseline,
replayRevision: revision,
dirty: execFileSync("git", ["status", "--porcelain"], { encoding: "utf8" }).trim() !== "",
sameRevision: revision === queries.baseline,
mode: "place-only source replay; shortcuts and live UI are separate evidence",
cases: responses.cases.map((fixture) => {
const entry = queries.cases.find((query) => query.id === fixture.caseId);
if (!entry) throw new Error("Missing business retrieval query");
const context = { query: entry.query, proximity: queries.center, zoom: queries.zoom };
// The historical aggregate was empty/partial. Replay the captured geocoder
// pool; do not treat the unavailable aggregate as source absence.
const combined = fixture.rawIds.map((id) => {
const feature = responses.features.find((candidate) => candidate.raw.id === id);
if (!feature) throw new Error("Missing captured candidate");
return feature.adapted;
});
const ranked = rankAutocompleteRows({ places: combined }, context);
const action = enterAction(ranked, context);
return {
caseId: fixture.caseId,
variants: getQueryVariants(entry.query),
partial: true,
combinedIds: combined.map((row) => row.id),
rankedIds: ranked.map((row) => row.id),
enter: action.kind === "open" ? { kind: "open", id: action.row.id } : action,
};
}),
};
const output = `${JSON.stringify(report, null, 2)}\n`;
const outIndex = process.argv.indexOf("--out");
if (outIndex >= 0) {
const out = process.argv[outIndex + 1];
if (!out) throw new Error("Missing --out path");
writeFileSync(out, output);
} else process.stdout.write(output);
Loading
Loading