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