Spaces:
Sleeping
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/:
- 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
https://raw.githubusercontent.com/glottolog/glottolog-cldf/master/cldf/languages.csvhttps://raw.githubusercontent.com/cldf-datasets/wals/master/cldf/languages.csvhttps://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:
- Load Glottolog CLDF. Rows with
Level == "family"giveFamily_ID -> family name. Rows withLevel in {language, dialect}give glottocode, ISO, coords, macroarea, isolate flag. - Load atlas
languages.parquetfor vitality (status), atlas coordinates (fill if Glottolog coords missing), andphoneme_count. - Collect every distinct
langstring from etymologylang1andlang2. - 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.
- If the key is a 3-letter ISO 639-3 code (Lexibank), map via Glottolog
- Family name: Glottolog family lookup. Fallback: if the key starts with
proto-, use the remainder title-cased as a pseudo-family (soproto-germanicgroups with Germanic when the family row exists, otherwise "Proto-Germanic"). - Macroarea: Glottolog, then atlas parquet.
- WALS join:
wals_languages.Glottocode-> atlas glottocode. Storewals_id. - Phoneme join: PHOIBLE
glottocode. Union inventories when multiple PHOIBLE sources exist for one language. - Assign dense integer codes for
lang,family,macroarea,statusused 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
- Read etymologies in one pandas load (58 MB parquet; ~1β2 GB peak is acceptable).
- Drop rows with null/empty
term1orterm2. - Intern
(term, lang)-> densenode_idstarting at 0. Process both sides. Preserve original term spelling asterm; storeterm_normfor FTS (NFKC, casefold, strip leading*, mapβ/β->2/3). - Map
relationship_typeto uint8 via a fixed table (order stable, stored inmeta.json). - Quantize confidence to uint8 as
round(conf * 100). - 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):
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-1and pred uses those fields, returnpred.keep_unknown. - Term regex compiled once per query.
path_ok(node_ids, edge_rels, path_filter) -> bool:
- Slice intermediates.
- Quantifier over
node_okon that slice. - Then AND the relation-any / relation-none checks on
edge_rels.
walk(query) -> TreeResult:
- Resolve root id or return 404-equivalent empty result.
- BFS using reverse CSR. Skip edges failing Layer A. Store
parent[child] = node,parent_edge[child] = edge_index,depth[child]. - Identify candidate leaves: visited nodes with no kept child in the BFS tree, or depth == max_depth.
- For each candidate, reconstruct path by parent pointers, test leaf + path filters.
- Mark surviving nodes.
- Cluster unmarked-as-expanded high fan-out sibling groups.
- Hydrate payload rows from SQLite in one
WHERE id IN (...)query. - 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
Atlasobject
backend/app.py:
/api/*routes/and assets fromfrontend/distif 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}. devicePixelRatiocap at 2 for mobile GPUs.- Hit test:
grid[(gx<<16)^gy]lists of node indices. - Path highlight: walk
parentfrom 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
python pipeline/download.py && python pipeline/build_index.pycd frontend && npm install && npm run builduvicorn backend.app:app --host 0.0.0.0 --port 8000- Hit
/api/suggest?q=mater,/api/treefor Latin mater, depth 3. - Confirm elapsed_ms in the budget.
- Resize to 390px and check sheets, pinch, chips.
- README: citation, how to run, filter semantics, license.
File-level backend notes
backend/graph.py
class CSR: offsets, targets, rel, conf, srcclass Atlas: n, reverse, forward, node_traits, sqlite, meta, wals, phonemes, lang_indexchildren(node, edge_filter) -> iterator of edge indices
backend/search.py
- Parameterized FTS:
nodes_fts MATCH ?with prefixq*, 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.