Spaces:
Sleeping
Sleeping
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.
|