reverse-etymology / docs /IMPLEMENTATION.md
Mongoosetross's picture
Deploy Reverse Etymology Atlas
9729dcb verified
|
Raw
History Blame Contribute Delete
15.1 kB
# 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.