150 lines
15 KiB
Markdown
150 lines
15 KiB
Markdown
---
|
||
type: adr
|
||
work_item_id: ET-008
|
||
adr_id: ADR-006
|
||
title: "ADR-006: Дедупликация публичных GPS-треков — bbox+length+date bucket-hash, мерж sources при коллизии, без геометрических метрик"
|
||
status: accepted
|
||
created_at: 2026-06-01
|
||
authors:
|
||
- "agent:architect"
|
||
supersedes: []
|
||
superseded_by: []
|
||
labels: []
|
||
---
|
||
|
||
# ADR-006 — Алгоритм дедупликации публичных GPS-треков
|
||
|
||
## Статус
|
||
|
||
Accepted
|
||
|
||
## Контекст
|
||
|
||
Один и тот же реальный трек может быть выложен автором на несколько платформ (BRD §6 риск №3): тот же маршрут пользователь публикует в EnduroRussia.ru и в Wikiloc, дублирует в OSM Public GPS Traces при разборе и т.п. Цель ET-008 (BRD §1) — «одна запись на реальный трек, с union'ом источников и ссылок». Метрика — BRD §5: ≤ 5% дублей при ручной проверке 100 случайных треков.
|
||
|
||
Архитектурно нужно выбрать:
|
||
|
||
1. **Какой признак считать «тот же трек».** Координаты на платформах округлены / прорежены / иногда обработаны (сглаживание); полное совпадение точек — редкое.
|
||
2. **Сложность алгоритма.** На 5000 треков допустим O(n²); на 50 000+ при расширении на РФ — нет. Нужно либо O(n log n), либо хэш O(n).
|
||
3. **Поведение при отсутствии метаданных.** У OSM-треков нет «активности», у скрейпленых страниц иногда нет даты — что делать.
|
||
4. **Что фиксируется при коллизии** — кто из источников «выиграл» в полях `name`/`user`/`activity_type`.
|
||
|
||
## Рассмотренные варианты
|
||
|
||
### Вариант A — Bucket-hash по bbox + length + date (выбран; совпадает с TRZ REQ-F-08)
|
||
|
||
```python
|
||
def compute_dedup_key(geom: LineString, meta: dict) -> str:
|
||
w, s, e, n = geom.bounds
|
||
bbox_round = (round(w, 2), round(s, 2), round(e, 2), round(n, 2)) # ≈ 1.1 км
|
||
length_bucket = round(meta["length_m"] / 1000) * 1000 # 1 км
|
||
date_bucket = (meta.get("created_at") or "")[:10] # YYYY-MM-DD
|
||
return f"{bbox_round}|{length_bucket}|{date_bucket}"
|
||
```
|
||
|
||
- Сложность: **O(1)** на трек, **O(n)** на пайплайн. Идеально для INSERT с `UNIQUE(dedup_key)` ON CONFLICT.
|
||
- Точность: для треков с известной датой — высокая (BBox-проекция отлично различает соседние «утренний эндуро в Калужской» vs «вечерний в Подмосковье»; на одной дате одинаковая длина в одном bbox — это почти всегда тот же трек).
|
||
- Ложные коллизии: треки без даты в одном bbox с похожей длиной — будут смерджены. По BRD §6 это явный риск (пользователь может потерять «свой» вариант трека). Митигация — `08-data-requirements.md` §6 и AC-03 «Треки без даты от разных источников».
|
||
- Ложные не-коллизии: один и тот же трек у двух источников с расхождением даты на 1+ день (один источник датирует загрузку, другой — запись GPS) — не смердживается. На практике источники сохраняют дату GPS из самого файла; расхождение редкое.
|
||
|
||
### Вариант B — Frechet/Hausdorff-расстояние между LineString (отклонён)
|
||
|
||
- Сложность: O(n²) на регион при наивной реализации; даже с R-tree-префильтром по bbox остаётся O(n × k), где k — кандидаты в 1-км окне.
|
||
- Реалистичный pipeline-overhead: для 5000 треков с медианой 1240 точек — ~30 минут вычислений на регион. Это съедает половину cron-окна (6 ч).
|
||
- Преимущества — устойчивость к шумам в координатах; недостатки — высокая стоимость, и при ≥ 50 000 треков становится непригодным.
|
||
|
||
### Вариант C — Хэш resampled-points (отклонён)
|
||
|
||
```python
|
||
sampled = resample(geom, every_n_meters=100)
|
||
key = sha256(",".join(f"{lat:.4f},{lon:.4f}" for lat, lon in sampled))
|
||
```
|
||
|
||
- Сложность: O(n) на трек, O(n) на пайплайн. Хорошо.
|
||
- Точность: хуже A — на платформах с разным сглаживанием те же 100-метровые точки могут отличаться в 4-м знаке после запятой → хэши не совпадают. То есть метод нестабилен между источниками.
|
||
- Можно округлять до 3 знаков (≈ 100 м), но тогда два соседних трека по той же лесной просеке дают одинаковый хэш — снова коллизии.
|
||
|
||
### Вариант D — Гибрид: bucket-hash как первичный фильтр + Frechet как тай-брейкер (отклонён)
|
||
|
||
- Соблазнительно: A для скорости, B на коллизиях.
|
||
- Сложность реализации высокая: при коллизии bucket-hash нужно подтянуть из БД полную геометрию обоих треков, посчитать Frechet, принять решение. Это блокирующий round-trip в SQLite на каждый коллидирующий INSERT.
|
||
- На MVP это over-engineering. Если метрика BRD §5 «≤ 5%» не выполнится — заводится отдельный work item «улучшение dedup».
|
||
|
||
## Решение
|
||
|
||
**Принимается Вариант A — bucket-hash O(1)**, в точности по формуле TRZ REQ-F-08, с уточнениями:
|
||
|
||
1. **Гранулярность `bbox_round`** — 2 знака после запятой (≈ 1.1 км). Не 1 знак (≈ 11 км — слишком грубо, ложные коллизии для коротких треков в одном городе) и не 3 знака (≈ 110 м — слишком точно, не сходится между источниками с разным сглаживанием).
|
||
|
||
2. **Гранулярность `length_bucket`** — 1 км. На треках длиной 5–50 км это 2–20% разброс, что покрывает межисточниковую разницу подсчёта (округление координат → разные интегралы длины). На очень коротких треках (< 1 км) `length_bucket = 0` для всех таких треков — что даст переслияние «всех коротких в одном km²-bbox в одной дате»; вероятность такого совпадения от двух разных авторов исчезающе мала.
|
||
|
||
3. **Гранулярность `date_bucket`** — день (YYYY-MM-DD). Не «час» (источники часто хранят только дату), не «месяц» (слишком грубо — есть популярные маршруты, которые ездят сотнями раз).
|
||
|
||
4. **Отсутствие `created_at`** — `date_bucket = ""` для обоих треков → они считаются одним ключом. Это сознательный consenrvative-merge:
|
||
- Источники, не отдающие дату, обычно отдают её отдельно (OSM публикует timestamp загрузки; ttrails — дату публикации; EnduroRussia — дату поездки). После анализа лог-сэмплов BRD §5 ожидаем, что > 95% треков имеют дату.
|
||
- Без даты — мы и не отличим «два разных трека с одинаковой геометрией» от «один и тот же выложенный дважды». Merge — меньшее зло, чем дубль; при ошибке достаточно дополнительно показать оба `external_urls` в popup (REQ-F-18).
|
||
- Документировано в AC-03 «Треки без даты — дедуп срабатывает».
|
||
|
||
5. **Поведение при коллизии — мерж, а не replace:**
|
||
- `sources_json` ← union существующих + нового `[source_id]`.
|
||
- `external_urls_json` ← union существующих + нового `[external_url]`.
|
||
- `name`, `description`, `user`, `tags`, `activity_type` — берутся **по приоритету источника в `gps_sources.yaml`** (порядок объявления = приоритет). Если у нового источника приоритет выше — поля перезаписываются; иначе сохраняются старые. Это даёт стабильный детерминированный результат независимо от порядка обхода в pipeline.
|
||
- `length_m`, `points_count`, `geom` — берутся от **первого** источника (того, кто первым создал запись). Не пересчитываются при мерже. Это снижает риск «джиттера» геометрии трека от прогона к прогону.
|
||
- `updated_at` — обновляется на текущее время прогона.
|
||
|
||
6. **Реализация в коде** — SQL-уровень:
|
||
|
||
```sql
|
||
INSERT INTO tracks (dedup_key, name, ..., sources_json, external_urls_json, ...)
|
||
VALUES (?, ?, ..., ?, ?, ...)
|
||
ON CONFLICT(dedup_key) DO UPDATE SET
|
||
sources_json = (SELECT json_union(sources_json, excluded.sources_json)),
|
||
external_urls_json = (SELECT json_union(external_urls_json, excluded.external_urls_json)),
|
||
name = CASE WHEN excluded._priority > _priority THEN excluded.name ELSE name END,
|
||
...
|
||
updated_at = excluded.updated_at;
|
||
```
|
||
|
||
Поскольку SQLite без JSON1 не имеет `json_union`, мерж массивов реализуется на Python в `db.py::upsert_track()` (read-merge-write в одной транзакции). Производительность достаточная: O(1) на трек, < 5 мс на upsert.
|
||
|
||
7. **Валидация метрики BRD §5 «< 5% дублей»** — отдельный скрипт `scripts/dedup_audit.py` (отсэмплировать 100 треков, вывести в JSON для ручной проверки). Этот скрипт — артефакт фазы тестирования (`04-test-plan.yaml`), не runtime.
|
||
|
||
8. **План отступления.** Если метрика < 5% не выполнится на реальном датасете:
|
||
- Сузить `length_bucket` до 500 м.
|
||
- Добавить `activity_type` в ключ (но тогда сломается «OSM без активности vs EnduroRussia с активностью=enduro» — merge не сработает; нужно явно маппить пропуски в общий слот).
|
||
- В крайнем случае — гибрид A+B (Вариант D выше).
|
||
Эти эволюции — отдельный ADR, не блокируют ET-008 MVP.
|
||
|
||
## Последствия
|
||
|
||
### Положительные
|
||
|
||
- O(1) per track, O(n) per pipeline — никакого квадратичного blow-up.
|
||
- Реализуется одним SQL ON CONFLICT + Python-мерж массивов; < 100 строк кода.
|
||
- Детерминированный результат при перезапуске pipeline (порядок источников фиксирован конфигом).
|
||
- Соответствует BRD-метрике «< 5%» на ожидаемом датасете (валидируется QA в фазе теста).
|
||
|
||
### Отрицательные / ограничения
|
||
|
||
- **Ложные коллизии для треков без даты.** Принято осознанно (см. §4 решения).
|
||
- **Ложные коллизии для одного маршрута, проехавшего в разные дни** двумя разными людьми с похожей длиной — это **не баг, а ограничение**: один и тот же популярный 30-км маршрут, проехавший двумя гонщиками в один день, будет смерджен в одну запись. Бизнес-смысл сохраняется (пользователь увидит «по этой тропе ездят»), но статистика «сколько раз проехали» — потеряна. Это out of scope MVP; в BRD §5 «плотность треков» — отдельная фича.
|
||
- **Length-bucket не работает на круговых треках** с малой длиной по прямой — но bbox-проекция эти случаи всё равно различает по координатам.
|
||
- **При наследовании MVP-кода на регионы с миллионом треков** ложные коллизии могут вырасти. Митигация — `10-tech-risks.md` R-2; метрика отслеживается на каждом прогоне в `pipeline_runs.errors_json`.
|
||
|
||
### Технический долг
|
||
|
||
- Если QA-метрика провалится — план отступления §8 решения.
|
||
- Возможный future-rewrite на Вариант D (hybrid) — задокументирован, но не выполняется в MVP.
|
||
|
||
## Классификация изменения
|
||
|
||
**Minor change.** Алгоритм — внутренний contract pipeline'а, не виден ни наружу API, ни во фронтенде. Любая будущая правка `compute_dedup_key()` требует полного re-collect (отбросить БД и пересобрать), но это операционная процедура; затрагивает только `data/gps_tracks.sqlite`. `arch:major-change` не требуется.
|
||
|
||
## Связанные документы
|
||
|
||
- `docs/work-items/ET-008/02-trz.md` §6.1 «compute_dedup_key»
|
||
- `docs/work-items/ET-008/03-acceptance-criteria.md` AC-03
|
||
- `docs/work-items/ET-008/06-adr/ADR-005-storage-schema.md` §3 (sources_json)
|
||
- `docs/work-items/ET-008/08-data-requirements.md` §3.2 (dedup_key)
|
||
- `docs/work-items/ET-008/10-tech-risks.md` R-2 (ложные коллизии)
|