File size: 15,115 Bytes
0e7a159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9729dcb
0e7a159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
404
405
406
407
408
409
410
411
412
# Implementation plan

This is the build order. Each phase is independently shippable; later phases assume the artifacts of earlier ones.

## Phase 0 β€” repository skeleton

Create:

```
README.md
requirements.txt
.python-version                # 3.12
.gitignore                     # data/index, data/raw, node_modules, dist, __pycache__
pyproject.toml                 # pytest config, optional later
pipeline/download.py
pipeline/build_index.py
backend/app.py
backend/graph.py
backend/filters.py
backend/search.py
backend/models.py
backend/load.py
frontend/                      # Vite + React + TypeScript
tests/test_filters.py
tests/test_graph.py
tests/test_search.py
scripts/run.sh
docs/DESIGN.md                 # already written
docs/IMPLEMENTATION.md         # this file
```

Runtime stack:

- Python 3.12, FastAPI, uvicorn, numpy, pandas, pyarrow, orjson
- SQLite 3 (stdlib) with FTS5
- Node 22, Vite, React 18/19, TypeScript, d3-hierarchy
- Leaflet + a tiny QR library for the phone dialog

No Docker required. `scripts/run.sh` downloads (if needed), builds the index (if needed), builds the SPA (if needed), and serves everything on `:8000`.

## Phase 1 β€” download

`pipeline/download.py` fetches into `data/raw/`:

1. Hugging Face parquet files from `https://huggingface.co/datasets/lukeslp/etymology-atlas/resolve/main/{file}`:
   - etymologies.parquet
   - languages.parquet
   - cognate_sets.parquet
   - phonemes.parquet
   - linguistic_features.parquet
2. `https://raw.githubusercontent.com/glottolog/glottolog-cldf/master/cldf/languages.csv`
3. `https://raw.githubusercontent.com/cldf-datasets/wals/master/cldf/languages.csv`
4. `https://raw.githubusercontent.com/cldf-datasets/wals/master/cldf/codes.csv`

Idempotent: skip files whose size matches a recorded `.meta.json`. Retry with exponential backoff. Fail with a clear message if Hugging Face is unreachable.

## Phase 2 β€” language catalog

Inside `build_index.py`, build a `languages` table that actually has families and ISO codes (the published `languages.parquet` leaves `family_name`, `iso_639_3`, and `speakers_count` entirely null).

Algorithm:

1. Load Glottolog CLDF. Rows with `Level == "family"` give `Family_ID -> family name`. Rows with `Level in {language, dialect}` give glottocode, ISO, coords, macroarea, isolate flag.
2. Load atlas `languages.parquet` for vitality (`status`), atlas coordinates (fill if Glottolog coords missing), and `phoneme_count`.
3. Collect every distinct `lang` string from etymology `lang1` and `lang2`.
4. Resolve each atlas lang key to a glottocode:
   - If the key is a 3-letter ISO 639-3 code (Lexibank), map via Glottolog `ISO639P3code`.
   - Else lowercase-name match against Glottolog `Name`.
   - Else lowercase-name match against atlas `languages.name`.
   - Else try stripping a parenthetical dialect (`persian: tehran` -> `persian`) and retry.
   - Else leave unresolved; still store the atlas key as a first-class language so proto-languages work.
5. Family name: Glottolog family lookup. Fallback: if the key starts with `proto-`, use the remainder title-cased as a pseudo-family (so `proto-germanic` groups with Germanic when the family row exists, otherwise "Proto-Germanic").
6. Macroarea: Glottolog, then atlas parquet.
7. WALS join: `wals_languages.Glottocode` -> atlas glottocode. Store `wals_id`.
8. Phoneme join: PHOIBLE `glottocode`. Union inventories when multiple PHOIBLE sources exist for one language.
9. Assign dense integer codes for `lang`, `family`, `macroarea`, `status` used later as numpy columns.

Write `languages` SQLite table:

```
lang_key TEXT PK,
display TEXT,
glottocode TEXT,
iso_639_3 TEXT,
family_id TEXT,
family_name TEXT,
macroarea TEXT,
status TEXT,
latitude REAL,
longitude REAL,
wals_id TEXT,
phoneme_count INTEGER,
resolved INTEGER
```

Write auxiliary tables:

```
wals_values(glottocode, feature_id, feature_name, value INT, value_label TEXT)
phoneme_inv(glottocode, phoneme, segment_class, tone INT)
cognate_set(id, words_json, language_count)
cognate_member(set_id, term, lang, glottocode)
```

WALS `value_label` comes from `codes.csv` (`81A-2` -> `SOV` etc.), not the atlas `value_name` which is only the code id.

## Phase 3 β€” node internment and CSR graph

1. Read etymologies in one pandas load (58 MB parquet; ~1–2 GB peak is acceptable).
2. Drop rows with null/empty `term1` or `term2`.
3. Intern `(term, lang)` -> dense `node_id` starting at 0. Process both sides. Preserve original term spelling as `term`; store `term_norm` for FTS (NFKC, casefold, strip leading `*`, map `β‚‚`/`₃` -> `2`/`3`).
4. Map `relationship_type` to uint8 via a fixed table (order stable, stored in `meta.json`).
5. Quantize confidence to uint8 as `round(conf * 100)`.
6. Source to uint8: 0 = etymology-db, 1 = lexibank:iecor.

Reverse CSR (the walk direction):

```
offsets: int32[n_nodes + 1]
targets: int32[n_edges]      # descendant node ids
rel:     uint8[n_edges]
conf:    uint8[n_edges]
src:     uint8[n_edges]
```

Edge `term2 -> term1` is appended in input order, then each adjacency list is sorted by `(rel, -conf, target)` so inherited high-confidence children appear first.

Also build a forward CSR (`term1 -> term2`) for the inspector. Same arrays, `fwd_` prefix.

Per-node numpy (saved in the same npz or a second one):

```
lang_code:     int32[n_nodes]
family_code:   int32[n_nodes]
macro_code:    int8[n_nodes]
status_code:   int8[n_nodes]
glotto_code:   int32[n_nodes]   # -1 if unresolved
child_count:   int32[n_nodes]   # reverse out-degree
```

SQLite `nodes`:

```
id INTEGER PK,
term TEXT,
term_norm TEXT,
lang TEXT,
child_count INTEGER
```

FTS5:

```
CREATE VIRTUAL TABLE nodes_fts USING fts5(
  term, term_norm, lang,
  content='nodes', content_rowid='id',
  tokenize = 'unicode61 remove_diacritics 2'
);
```

`meta.json`: relation names, language/family/macroarea vocabularies with counts, example roots, cluster/depth defaults, dataset citation.

Build is a single command, prints timings, refuses to overwrite unless `--force`.

## Phase 4 β€” filter engine

`backend/filters.py` is pure and unit-tested with a synthetic graph. No FastAPI imports.

Dataclasses (mirrors the JSON body):

```python
class EdgeFilter:
    relations: frozenset[str]   # empty = default set
    min_confidence: float
    max_depth: int
    max_visit: int

class NodePredicate:
    languages: frozenset[str]
    families: frozenset[str]
    macroareas: frozenset[str]
    statuses: frozenset[str]
    term_contains: str
    term_regex: str | None
    bbox: tuple[float, float, float, float] | None  # minlat, minlon, maxlat, maxlon
    require_coords: bool
    wals: tuple[WalsClause, ...]   # feature_id + allowed int values
    phonemes_have: frozenset[str]
    phonemes_lack: frozenset[str]
    require_tone: bool | None
    keep_unknown: bool

class PathFilter:
    node: NodePredicate
    quantifier: Literal["any", "all", "none", "exactly"]
    exactly_k: int
    relations_any: frozenset[str]
    relations_none: frozenset[str]
    apply_to_root: bool

class TreeQuery:
    term: str
    lang: str
    edges: EdgeFilter
    leaf: NodePredicate
    path: PathFilter
    expand: tuple[str, ...]     # cluster keys to unpack
    cluster_threshold: int
    max_payload: int
    color_by: str
```

`node_ok(node_id, pred, ctx) -> bool`:

- Empty predicate fields are no-ops (vacuously true).
- WALS/phoneme lookups go through `glotto_code[node_id]`; if `-1` and pred uses those fields, return `pred.keep_unknown`.
- Term regex compiled once per query.

`path_ok(node_ids, edge_rels, path_filter) -> bool`:

- Slice intermediates.
- Quantifier over `node_ok` on that slice.
- Then AND the relation-any / relation-none checks on `edge_rels`.

`walk(query) -> TreeResult`:

1. Resolve root id or return 404-equivalent empty result.
2. BFS using reverse CSR. Skip edges failing Layer A. Store `parent[child] = node`, `parent_edge[child] = edge_index`, `depth[child]`.
3. Identify candidate leaves: visited nodes with no kept child in the BFS tree, or depth == max_depth.
4. For each candidate, reconstruct path by parent pointers, test leaf + path filters.
5. Mark surviving nodes.
6. Cluster unmarked-as-expanded high fan-out sibling groups.
7. Hydrate payload rows from SQLite in one `WHERE id IN (...)` query.
8. Return stats: visited, kept_leaves, clustered, elapsed_ms, truncated flag.

Do not recurse in Python objects; use arrays and deques. This is the hot path.

## Phase 5 β€” FastAPI

`backend/load.py` on startup:

- mmap `graph.npz`
- open SQLite with `pragma journal_mode=wal; mmap_size=268435456; cache_size=-80000`
- load `meta.json`
- load WALS/phoneme dicts keyed by glotto_code (built once from SQLite into Python dicts; 2k inventories is tiny)
- expose a process-global `Atlas` object

`backend/app.py`:

- `/api/*` routes
- `/` and assets from `frontend/dist` if present
- gzip via Starlette GZipMiddleware
- CORS `*`
- orjson response class
- request timing header `X-Query-Ms`

Validation: pydantic models matching `TreeQuery`. Unknown relation names 422. Empty suggest query returns the curated examples.

## Phase 6 β€” frontend

Vite React TS. Single page.

### State

URL is the source of truth. `useQueryState` (hand-rolled) serializes:

```
q, lang, depth, conf,
rels (comma),
view (tree|radial|map|table|stats),
color,
leafLang, leafFam, leafArea, leafWals, leafPh,
pathLang, pathFam, pathArea, pathMatch, pathRelAny,
expand
```

Changing any of these (except camera) refetches `/api/tree`. Camera is session-only.

### Components

| File | Responsibility |
| --- | --- |
| `App.tsx` | Shell, URL state, data fetching, layout mode (desktop vs mobile) |
| `SearchBar.tsx` | Combobox suggest, language disambiguation, `/` shortcut |
| `FilterPanel.tsx` | Three tabs: Graph / Leaves / Path. Multi-selects, sliders, quantifier, keep-unknown |
| `FilterChips.tsx` | Compact removable chips; the mobile summary of the query |
| `TreeCanvas.tsx` | Canvas camera + pointer/touch + hover path |
| `layout.ts` | d3-hierarchy tidy / radial, cluster stub sizing |
| `render.ts` | Draw edges, nodes, labels, LOD, minimap, DPR |
| `MapView.tsx` | Leaflet markers from hydrated coords |
| `TableView.tsx` | Sort, filter-in-view, pagination, CSV button |
| `StatsView.tsx` | Small-multiple bars for the current payload |
| `Inspector.tsx` | Desktop drawer / mobile sheet |
| `Legend.tsx` | Color encoding |
| `Examples.tsx` | First-run cards |
| `PhoneQR.tsx` | LAN origin QR |
| `api.ts` | fetch wrappers |
| `types.ts` | shared TS types matching pydantic |
| `colors.ts` | relation + family palettes |
| `pwa.ts` | service worker registration |

### Canvas details

- Offscreen node array `{x,y,r,id,kind,color,label}`.
- `devicePixelRatio` cap at 2 for mobile GPUs.
- Hit test: `grid[(gx<<16)^gy]` lists of node indices.
- Path highlight: walk `parent` from hovered id, draw those edges last.
- Cluster stubs drawn as rounded rects with count.
- Empty state when the query yields only the root: explain which layer dropped the descendants.

### Mobile chrome

```
[ search ................. ] [filters n]
[ tree | radial | map | table ]
<canvas flex>
[ inspector sheet handle ]
```

Bottom sheet uses a drag handle, 40% default height, snap to 90% for filter editing.

### PWA

`manifest.webmanifest`: name "Reverse Etymology Atlas", standalone, theme color ink navy, 192/512 icons (simple SVG-generated PNGs checked in or generated at build).

Service worker: cache-first for `/assets/*` and `/`, network-first for `/api/*`.

## Phase 7 β€” tests

Synthetic graph in `tests/conftest.py`:

```
PIE *kaput -> Latin caput (inherited)
Latin caput -> Old French chef (inherited)
Old French chef -> English chief (borrowed)
Latin caput -> English capital (derived)   # no French hop
Latin caput -> French chef (inherited)
French chef -> English chef (borrowed)
```

Cases:

- No filters: all descendants present.
- Leaf language English: *chief*, *capital*, *chef* (English); French *chef* excluded; Latin kept as ancestor.
- Leaf English AND path any French/Old French: *chief* and English *chef* kept; *capital* dropped.
- Path none French: *capital* kept; *chief* dropped.
- Path all Romance family: depends on assigned families in the fixture.
- Relation construction without `borrowed`: English *chef* and *chief* disappear.
- Quantifier exactly 1.
- Cluster threshold unpack.
- Cycle: A->B->A does not infinite loop.
- Suggest ranks exact over prefix.
- API 404 on unknown root.

Run with `pytest -q`. The synthetic atlas is built in-memory; no parquet required.

Optional smoke: if `data/index/graph.npz` exists, one test queries Latin `mater` and asserts at least one inherited Romance daughter.

## Phase 8 β€” run, measure, polish

1. `python pipeline/download.py && python pipeline/build_index.py`
2. `cd frontend && npm install && npm run build`
3. `uvicorn backend.app:app --host 0.0.0.0 --port 8000`
4. Hit `/api/suggest?q=mater`, `/api/tree` for Latin mater, depth 3.
5. Confirm elapsed_ms in the budget.
6. Resize to 390px and check sheets, pinch, chips.
7. README: citation, how to run, filter semantics, license.

## File-level backend notes

`backend/graph.py`

- `class CSR: offsets, targets, rel, conf, src`
- `class Atlas: n, reverse, forward, node_traits, sqlite, meta, wals, phonemes, lang_index`
- `children(node, edge_filter) -> iterator of edge indices`

`backend/search.py`

- Parameterized FTS: `nodes_fts MATCH ?` with prefix `q*`, fallback LIKE for 1-character queries (FTS5 is weak there).
- Limit 20.

## Frontend performance notes

- Debounce suggest at 80 ms, tree refetch at 150 ms for slider drags.
- AbortController cancels in-flight tree requests when the query changes.
- Do not React-render every canvas frame; canvas is an imperative module.
- Table view windows 100 rows; full payload stays in memory (max 2500).

## Risks and mitigations

| Risk | Mitigation |
| --- | --- |
| `other` + compounds + cognates can turn affix queries into large bushy trees | Cluster stubs; max_visit; users can uncheck noisy relation types per query |
| Empty atlas family column | Glottolog enrichment |
| Lexibank ISO keys vs Wiktionary names | Dual resolver; show both in suggest |
| Cognate cliques | Cognates excluded from reverse walk by default; inspector only |
| Mobile canvas jank | DPR cap, LOD, cluster, 2500 node cap |
| Hugging Face download flaky | Retry, documented mirror paths, skip if files exist |
| WALS sparse coverage | `keep_unknown` toggle, fail-closed default |

## Acceptance criteria

- [ ] Design and this plan committed.
- [ ] Index builds from the published parquet files without manual cleanup.
- [ ] Searching `mater` + language Latin shows Romance inherited daughters.
- [ ] Leaf filter English + path filter French keeps only English descendants whose path includes French.
- [ ] Toggling cognates/compounds/other changes the tree.
- [ ] Map, table, stats, radial all consume the same filtered payload.
- [ ] Layout is usable at 390px width with touch pan/zoom.
- [ ] Filter unit tests pass without the full dataset.
- [ ] README documents run steps and CC BY-SA citation.