place-service 0.5.0

Place Service - A place administration microservice that interoperates with the place-matcher crate
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
# Place Service — Index

Centralised registry of geographic places: hospitals, clinics,
branches, civic structures, landforms, administrative areas, business
locations. Domain model aligned with
[schema.org/Place](https://schema.org/Place). Probabilistic +
deterministic matching, geo-radius search (Haversine + bounding-box),
real-time and batch deduplication, GDPR Article 15 export, audit
trail.

This page is a **navigation aid with worked examples**. For canonical
behaviour, read [`spec.md`](spec.md).

## Documentation map

| File | Role |
|------|------|
| [`spec.md`]spec.md | **Single source of truth.** What the system does, how it is built, NFRs, tasks (§13), open questions (§16). |
| [`README.md`]README.md / [`CLAUDE.md`]CLAUDE.md | User-facing intro — must stay consistent with the spec. |
| [`AGENTS.md`]AGENTS.md | Agent-facing entry point — `AGENTS/*` directory + shared docs. |
| [`AGENTS/spec-driven-development.md`]AGENTS/spec-driven-development.md | The SDD discipline this crate practises. |
| [`AGENTS/models.md`]AGENTS/models.md | Field-by-field domain model reference. |
| [`AGENTS/matching.md`]AGENTS/matching.md | Match weights, components, deterministic rules, Soundex. |
| [`AGENTS/restful.md`]AGENTS/restful.md | Endpoint catalogue + library API. |
| [`AGENTS/testing.md`]AGENTS/testing.md | Unit / integration / benchmark layout. |
| [`agents/share/*`]../agents/share/ | Project-wide cross-crate references. |

## Quick start

```bash
# REST API
cargo run --release

# Web UI (Loco / Tera / HTMX / Alpine / Lily)
cargo run --bin web                    # → http://0.0.0.0:5150
PORT=5180 cargo run --bin web

# Tests
cargo test --lib                       # 104 unit
cargo test --tests                     # 67 integration
cargo bench                            # 16 Criterion benchmarks
```

## URL surface (REST)

| Method | Path | Notes |
|---|---|---|
| GET | `/api/health` | Liveness |
| POST | `/api/places` | Create — `409` on detected duplicate |
| GET | `/api/places/{id}` | Read |
| PUT | `/api/places/{id}` | Update |
| DELETE | `/api/places/{id}` | Soft delete |
| GET | `/api/places/search` | Full-text + fuzzy |
| GET | `/api/places/nearby?lat=&lon=&radius_km=` | **Geo-radius search** |
| POST | `/api/places/match` | Score against candidates |
| POST | `/api/places/duplicates` | Real-time dup check |
| POST | `/api/places/merge` | Merge survivor + duplicate |
| POST | `/api/places/deduplicate` | Batch dedup scan |
| GET | `/api/places/{id}/masked` | Privacy view |
| GET | `/api/places/{id}/export` | GDPR Art. 15 export |
| GET | `/api/places/{id}/audit` | Per-record audit |
| GET | `/api/audit/recent` | System-wide recent audit |
| GET | `/api/audit/user` | Per-user audit |

This crate does **not** expose a FHIR R5 surface. See
[`spec.md §9`](spec.md#9-api-surface).

## Worked examples

### Create a place (Central Park)

```bash
curl -X POST http://localhost:8080/api/places \
  -H 'content-type: application/json' \
  -d '{
    "name": "Central Park",
    "alternate_name": "The Central Park",
    "description": "Urban park in Manhattan, New York City",
    "place_type": "Park",
    "address": {
      "street_address": "14 E 60th St",
      "address_locality": "New York",
      "address_region": "NY",
      "address_country": "US",
      "postal_code": "10022"
    },
    "geo": { "latitude": 40.7829, "longitude": -73.9654 },
    "telephone": "+1-212-310-6600",
    "url": "https://www.centralparknyc.org",
    "is_accessible_for_free": true,
    "public_access": true
  }'
```

If an existing place matches deterministically (same GLN) or is
within proximity threshold with a name match, the response is
`409 Conflict` with the candidate matches.

### Create a place with GLN

```bash
curl -X POST http://localhost:8080/api/places \
  -H 'content-type: application/json' \
  -d '{
    "name": "Acme Distribution Center 42",
    "place_type": "LocalBusiness",
    "global_location_number": "0614141999996",
    "geo": { "latitude": 37.7749, "longitude": -122.4194 }
  }'
```

### Check for duplicates

```bash
curl -X POST http://localhost:8080/api/places/duplicates \
  -H 'content-type: application/json' \
  -d '{
    "name": "Central Park",
    "address": {
      "address_locality": "New York",
      "address_region": "NY"
    },
    "geo": { "latitude": 40.7829, "longitude": -73.9654 }
  }'
```

### Search (full-text)

```bash
curl "http://localhost:8080/api/places/search?\
q=Central+Park&limit=10&offset=0&fuzzy=true&mask_sensitive=true"
```

### Geo-radius search

```bash
curl "http://localhost:8080/api/places/nearby?\
lat=40.7829&lon=-73.9654&radius_km=2.0&limit=20"
```

Returns places within 2 km of the supplied coordinates, sorted by
distance ascending. Today: Haversine + bounding-box pre-filter
(app-side). PostGIS-backed spatial indexing is queued as
[`spec.md` T-1](spec.md#13-tasks).

### Match a place

```bash
curl -X POST http://localhost:8080/api/places/match \
  -H 'content-type: application/json' \
  -d '{
    "name": "Centrl Park",
    "address": { "address_locality": "New York" },
    "geo": { "latitude": 40.783, "longitude": -73.965 },
    "threshold": 0.7
  }'
```

Returns ranked candidates with `score`, `confidence`, and a
per-component `breakdown` (`name_score` / `geo_score` /
`address_score` / `place_type_score` / `identifier_score`).

### Merge

```bash
curl -X POST http://localhost:8080/api/places/merge \
  -H 'content-type: application/json' \
  -d '{
    "main_place_id": "11111111-1111-1111-1111-111111111111",
    "duplicate_place_id": "22222222-2222-2222-2222-222222222222",
    "merge_reason": "Confirmed duplicate"
  }'
```

### Batch deduplication

```bash
curl -X POST http://localhost:8080/api/places/deduplicate \
  -H 'content-type: application/json' \
  -d '{
    "threshold": 0.70,
    "auto_merge_threshold": 0.95,
    "max_candidates": 50
  }'
```

### GDPR Article 15 export

```bash
curl "http://localhost:8080/api/places/{id}/export"
```

### Masked view

```bash
curl "http://localhost:8080/api/places/{id}/masked"
```

Returns the place with telephone / fax masked and geo coordinates
rounded to 2 decimal places (~1 km precision).

## Library API examples

### Validate and normalise

```rust
use place_service::models::place::Place;
use place_service::models::address::PostalAddress;
use place_service::models::geo::GeoCoordinates;
use place_service::models::place_type::PlaceType;
use place_service::validation::{validate_place, normalize_place};

let mut place = Place::new("Central Park");
place.address = Some(PostalAddress {
    street_address: Some("14 E 60th St".into()),
    address_locality: Some("new york".into()),
    address_region: Some("ny".into()),
    address_country: Some("us".into()),
    postal_code: Some("10022".into()),
});
place.geo = Some(GeoCoordinates::new(40.7829, -73.9654));
place.place_type = Some(PlaceType::Park);

let errs = validate_place(&place);
assert!(errs.is_empty(), "validation failed: {errs:?}");

normalize_place(&mut place);
// address_locality → "New York", address_region → "NY", country → "US"
```

### Match two places

```rust
use place_service::matching::scoring::{compute_match, MatchWeights};

let a = Place::new("Central Park");
let b = Place::new("Centrl Park");  // typo

let result = compute_match(&a, &b, &MatchWeights::default());
println!("score={:.3} confidence={:?}", result.score, result.confidence);
println!("  name: {:.3}", result.breakdown.name_score);
println!("  geo:  {:.3}", result.breakdown.geo_score);
```

### Match via the canonical `place-matcher` bridge

Use the sibling `place-matcher` crate as the reference algorithm. The
service re-exports it as `matcher_lib`, and `adapter::to_matcher_place`
projects the service's domain model into the matcher's input shape
(including national-identifier routing by FHIR `system` URI, address
field renaming, and identifier-scheme dispatch).

```rust,no_run
use place_service::matching::adapter::to_matcher_place;
use place_service::matching::matcher_lib::{Confidence, MatchConfig, MatchingEngine};
use place_service::models::*;

let mut a = Place::new("Central Park");
a.global_location_number = Some("0614141999996".into());
a.geo = Some(GeoCoordinates { latitude: 40.7829, longitude: -73.9654, elevation: None });
let mut b = Place::new("Centrl Park"); // typo
b.global_location_number = Some("0614141999996".into()); // same GLN

let engine = MatchingEngine::new(MatchConfig::default());
let result = engine.match_places(&to_matcher_place(&a), &to_matcher_place(&b));

assert!(result.is_match, "near-duplicate should classify as match");
assert_eq!(result.confidence, Confidence::High);
println!("score   = {:.3}", result.score);
println!("conf    = {:?}", result.confidence);
// `result.breakdown` carries per-field Option<f64> for an auditable trail.
```

End-to-end pinning lives in
[`tests/duplicate_detection.rs`](tests/duplicate_detection.rs); the
adapter source is [`src/matching/adapter.rs`](src/matching/adapter.rs).


### Geo distance and radius

```rust
use place_service::models::geo::GeoCoordinates;
use place_service::matching::geo::{geo_similarity, within_radius};

let nyc = GeoCoordinates::new(40.7128, -74.0060);
let lax = GeoCoordinates::new(33.9425, -118.4081);

let distance_m = nyc.distance_to(&lax);              // ~3 944 000 m
let close      = within_radius(&nyc, &lax, 5_000.0); // false
let sim        = geo_similarity(&nyc, &lax);         // ~0.0003
```

### Privacy mask + GDPR export

```rust
use place_service::privacy::{mask_place, gdpr_export};

let mut place = Place::new("Sensitive Place");
place.telephone = Some("+1-555-867-5309".into());
place.geo = Some(GeoCoordinates::new(40.78293456, -73.96543210));

let masked = mask_place(&place);
// telephone: "+1-555-867****"
// geo: (40.78, -73.97) — ~1 km precision

let export = gdpr_export(&place);
```

## Configuration

| Variable | Description | Default |
|---|---|---|
| `DATABASE_URL` | PostgreSQL connection string | _required_ |
| `DATABASE_MIN_CONNECTIONS` / `DATABASE_MAX_CONNECTIONS` | Pool sizes | `2` / `10` |
| `SERVER_HOST` | REST bind address | `0.0.0.0` |
| `SERVER_PORT` | REST port | `8080` |
| `PORT` | Web UI port (`cargo run --bin web`) | `5150` |
| `SEARCH_INDEX_PATH` | Tantivy index directory | `./search_index` |
| `MATCHING_THRESHOLD` | Default match cutoff | `0.7` |
| `OTLP_ENDPOINT` | OpenTelemetry collector | `http://localhost:4317` |
| `OTLP_SERVICE_NAME` | OTel `service.name` | `place-service` |
| `RUST_LOG` | `tracing-subscriber` filter | `info,place_service=info` |

## Project layout

```
src/
├── lib.rs              # Library root
├── main.rs             # Binary entry point
├── models/             # Place, PostalAddress, GeoCoordinates, PlaceType, PlaceIdentifier, …
├── matching/           # name, address, geo, identifier, phonetic, scoring
├── validation/         # validate_place, normalize_place
├── privacy/            # mask_place, gdpr_export
├── api/                # REST + gRPC (stub)
├── web/                # Loco app + Tera views + Axum web router
└── bin/web.rs          # cargo run --bin web

assets/views/           # Tera templates (HTMX + Alpine + Lily)
assets/static/          # lily.css, htmx.min.js, alpine.min.js
config/                 # development.yaml, test.yaml, production.yaml
migrations/             # SeaORM up.sql / down.sql pairs
tests/                  # integration_* (matching, validation, privacy, models, scoring, edge_cases)
benches/                # matching, validation, searching, database_reading/writing, privacy
AGENTS/                 # Reference documentation
```

## Key types

| Type | Module | Description |
|---|---|---|
| `Place` | `models::place` | Core place entity (schema.org/Place) |
| `PostalAddress` | `models::address` | Structured address |
| `GeoCoordinates` | `models::geo` | Lat / lon / elevation with Haversine |
| `PlaceType` | `models::place_type` | Classification enum (LocalBusiness, Park, Hospital, …) |
| `PlaceIdentifier` | `models::identifier` | External IDs (GLN, FIPS, GNIS, OSM, Custom) |
| `AmenityFeature` | `models::amenity` | Place amenity features |
| `OpeningHoursSpecification` | `models::opening_hours` | Operating hours |
| `Consent` | `models::consent` | GDPR consent record |
| `MatchResult` / `MatchBreakdown` | `matching::scoring` | Score + per-component detail |
| `MatchWeights` | `matching::scoring` | Configurable scoring weights |
| `MatchConfidence` | `matching::scoring` | Certain / Probable / Possible / Unlikely |
| `ValidationError` | `validation` | Field + message |

## Key functions

| Function | Module | Description |
|---|---|---|
| `compute_match` | `matching::scoring` | Match two places with weighted scoring |
| `name_similarity` | `matching::name` | Jaro-Winkler |
| `address_similarity` | `matching::address` | Weighted-field comparison |
| `geo_similarity` | `matching::geo` | Haversine + sigmoid decay |
| `geo_similarity_with_reference` | `matching::geo` | Custom reference distance |
| `within_radius` | `matching::geo` | Radius predicate |
| `identifier_similarity` | `matching::identifier` | Exact `(type, value)` |
| `has_gln_match` | `matching::identifier` | GLN deterministic short-circuit |
| `soundex` / `soundex_match` | `matching::phonetic` | 4-char phonetic |
| `validate_place` | `validation` | Required + format + invariants |
| `normalize_place` | `validation` | Title-case + uppercase + abbreviation expansion |
| `mask_place` | `privacy` | Phone / fax / geo masking |
| `gdpr_export` | `privacy` | GDPR Article 15 export |

## Status & roadmap

- **Status** — see [`spec.md §14`]spec.md#14-implementation-status.
- **Tasks** — see [`spec.md §13`]spec.md#13-tasks, including PostGIS
  spatial queries (T-1), OSM import (T-5), reverse-geocoding (T-6),
  GeoJSON export (T-7).
- **Roadmap** — see [`spec.md §15`]spec.md#15-roadmap.
- **Open questions** — see [`spec.md §16`]spec.md#16-open-questions,
  including tile-server choice (OQ-1).

## Compliance

| Standard | Mechanism |
|---|---|
| GDPR Art. 15 | `/api/places/{id}/export` |
| GDPR Art. 17 | Soft delete + consent revocation |
| schema.org/Place | Domain-model conformance |
| ISO/IEC 27001 | Operational controls (deployment-side) |

## License

Dual-licensed: MIT OR Apache-2.0.