Mongoosetross commited on
Commit
6efeb11
·
verified ·
1 Parent(s): a4e3218

Deploy Reverse Etymology Atlas

Browse files
backend/atlas.py CHANGED
@@ -122,6 +122,8 @@ class Atlas:
122
  phonemes=phonemes,
123
  has_tone=has_tone,
124
  rel_names=RELATIONS,
 
 
125
  )
126
  self.loaded = True
127
  self.load_ms = (time.perf_counter() - t0) * 1000
@@ -608,6 +610,12 @@ def walk_tree(atlas: Atlas, query: TreeQuery, root: int, t0: float) -> dict:
608
  node["longitude"] = info.get("longitude")
609
  node["glottocode"] = info.get("glottocode")
610
  node["iso_639_3"] = info.get("iso_639_3")
 
 
 
 
 
 
611
 
612
  elapsed = (time.perf_counter() - t0) * 1000
613
  root_info = extra.get(root) or atlas.hydrate([root]).get(root, {})
 
122
  phonemes=phonemes,
123
  has_tone=has_tone,
124
  rel_names=RELATIONS,
125
+ freq_zipf=arrays.get("freq_zipf"),
126
+ freq_rank=arrays.get("freq_rank"),
127
  )
128
  self.loaded = True
129
  self.load_ms = (time.perf_counter() - t0) * 1000
 
610
  node["longitude"] = info.get("longitude")
611
  node["glottocode"] = info.get("glottocode")
612
  node["iso_639_3"] = info.get("iso_639_3")
613
+ if atlas.ctx and atlas.ctx.freq_zipf is not None:
614
+ nid = int(node["id"])
615
+ z = float(atlas.ctx.freq_zipf[nid])
616
+ r = int(atlas.ctx.freq_rank[nid]) if atlas.ctx.freq_rank is not None else 0
617
+ node["zipf"] = round(z, 3) if z > 0 else None
618
+ node["freq_rank"] = r if r > 0 else None
619
 
620
  elapsed = (time.perf_counter() - t0) * 1000
621
  root_info = extra.get(root) or atlas.hydrate([root]).get(root, {})
backend/filters.py CHANGED
@@ -30,10 +30,22 @@ class FilterContext:
30
  phonemes: Mapping[int, set[str]]
31
  has_tone: Mapping[int, bool]
32
  rel_names: list[str]
 
 
33
 
34
  def lang_key(self, node_id: int) -> str:
35
  return self.lang_vocab[int(self.lang_code[node_id])]
36
 
 
 
 
 
 
 
 
 
 
 
37
  def family_name(self, node_id: int) -> str:
38
  lc = int(self.lang_code[node_id])
39
  fc = int(self.lang_family[lc])
@@ -76,6 +88,8 @@ def predicate_empty(pred: NodePredicate) -> bool:
76
  pred.phonemes_have,
77
  pred.phonemes_lack,
78
  pred.require_tone is not None,
 
 
79
  ]
80
  )
81
 
@@ -173,6 +187,19 @@ def node_ok(node_id: int, pred: NodePredicate, ctx: FilterContext, compiled_rege
173
  tone = bool(ctx.has_tone.get(gcode, False))
174
  if tone is not pred.require_tone:
175
  return False
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  return True
177
 
178
 
 
30
  phonemes: Mapping[int, set[str]]
31
  has_tone: Mapping[int, bool]
32
  rel_names: list[str]
33
+ freq_zipf: object | None = None # float32[n_nodes]
34
+ freq_rank: object | None = None # uint32[n_nodes]
35
 
36
  def lang_key(self, node_id: int) -> str:
37
  return self.lang_vocab[int(self.lang_code[node_id])]
38
 
39
+ def zipf(self, node_id: int) -> float:
40
+ if self.freq_zipf is None:
41
+ return 0.0
42
+ return float(self.freq_zipf[node_id])
43
+
44
+ def rank(self, node_id: int) -> int:
45
+ if self.freq_rank is None:
46
+ return 0
47
+ return int(self.freq_rank[node_id])
48
+
49
  def family_name(self, node_id: int) -> str:
50
  lc = int(self.lang_code[node_id])
51
  fc = int(self.lang_family[lc])
 
88
  pred.phonemes_have,
89
  pred.phonemes_lack,
90
  pred.require_tone is not None,
91
+ pred.min_zipf is not None,
92
+ pred.max_rank is not None,
93
  ]
94
  )
95
 
 
187
  tone = bool(ctx.has_tone.get(gcode, False))
188
  if tone is not pred.require_tone:
189
  return False
190
+
191
+ if pred.min_zipf is not None or pred.max_rank is not None:
192
+ z = ctx.zipf(node_id)
193
+ r = ctx.rank(node_id)
194
+ unknown_freq = r <= 0 and z <= 0
195
+ if unknown_freq:
196
+ if not pred.keep_unknown:
197
+ return False
198
+ else:
199
+ if pred.min_zipf is not None and z < pred.min_zipf:
200
+ return False
201
+ if pred.max_rank is not None and (r <= 0 or r > pred.max_rank):
202
+ return False
203
  return True
204
 
205
 
backend/frequency.py ADDED
@@ -0,0 +1,339 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ """Modern-language word popularity (Zipf + within-language rank).
4
+
5
+ Primary: Robyn Speer's ``wordfreq`` (Wikipedia, subtitles, news, books, web, social).
6
+ Fallback: HermitDave FrequencyWords (OpenSubtitles) for languages wordfreq lacks —
7
+ practical stand-in for popular usage when FastText crawl ``.bin`` models (multi-GB
8
+ each) cannot ship in a Space image. Optional ``FASTTEXT_FREQ_DIR/{lang}.txt`` ranked
9
+ vocab files are also consulted when present (one word per line, most frequent first).
10
+ """
11
+
12
+ import math
13
+ import os
14
+ import re
15
+ from functools import lru_cache
16
+ from pathlib import Path
17
+
18
+ ROOT = Path(__file__).resolve().parents[1]
19
+ FREQ_DIR = ROOT / "data" / "freq"
20
+ FASTTEXT_DIR = Path(os.environ.get("FASTTEXT_FREQ_DIR", str(FREQ_DIR / "fasttext")))
21
+
22
+ # Atlas lang key / ISO 639-3 → wordfreq / FrequencyWords codes.
23
+ LANG_TO_CODE: dict[str, str] = {
24
+ "english": "en",
25
+ "spanish": "es",
26
+ "french": "fr",
27
+ "german": "de",
28
+ "italian": "it",
29
+ "portuguese": "pt",
30
+ "dutch": "nl",
31
+ "russian": "ru",
32
+ "polish": "pl",
33
+ "swedish": "sv",
34
+ "norwegian": "nb",
35
+ "danish": "da",
36
+ "finnish": "fi",
37
+ "hungarian": "hu",
38
+ "czech": "cs",
39
+ "slovak": "sk",
40
+ "romanian": "ro",
41
+ "bulgarian": "bg",
42
+ "greek": "el",
43
+ "turkish": "tr",
44
+ "arabic": "ar",
45
+ "hebrew": "he",
46
+ "hindi": "hi",
47
+ "bengali": "bn",
48
+ "indonesian": "id",
49
+ "malay": "ms",
50
+ "vietnamese": "vi",
51
+ "thai": "th",
52
+ "chinese": "zh",
53
+ "japanese": "ja",
54
+ "korean": "ko",
55
+ "ukrainian": "uk",
56
+ "catalan": "ca",
57
+ "croatian": "hr",
58
+ "serbian": "sr",
59
+ "slovenian": "sl",
60
+ "lithuanian": "lt",
61
+ "latvian": "lv",
62
+ "estonian": "et",
63
+ "persian": "fa",
64
+ "urdu": "ur",
65
+ "tamil": "ta",
66
+ "tagalog": "tl",
67
+ "filipino": "tl",
68
+ "icelandic": "is",
69
+ "basque": "eu",
70
+ "galician": "gl",
71
+ "eng": "en",
72
+ "spa": "es",
73
+ "fra": "fr",
74
+ "fre": "fr",
75
+ "deu": "de",
76
+ "ger": "de",
77
+ "ita": "it",
78
+ "por": "pt",
79
+ "nld": "nl",
80
+ "dut": "nl",
81
+ "rus": "ru",
82
+ "pol": "pl",
83
+ "swe": "sv",
84
+ "nor": "nb",
85
+ "nob": "nb",
86
+ "dan": "da",
87
+ "fin": "fi",
88
+ "hun": "hu",
89
+ "ces": "cs",
90
+ "cze": "cs",
91
+ "slk": "sk",
92
+ "ron": "ro",
93
+ "rum": "ro",
94
+ "bul": "bg",
95
+ "ell": "el",
96
+ "gre": "el",
97
+ "tur": "tr",
98
+ "arb": "ar",
99
+ "heb": "he",
100
+ "hin": "hi",
101
+ "ben": "bn",
102
+ "ind": "id",
103
+ "msa": "ms",
104
+ "vie": "vi",
105
+ "tha": "th",
106
+ "cmn": "zh",
107
+ "zho": "zh",
108
+ "jpn": "ja",
109
+ "kor": "ko",
110
+ "ukr": "uk",
111
+ "cat": "ca",
112
+ "hrv": "hr",
113
+ "srp": "sr",
114
+ "slv": "sl",
115
+ "lit": "lt",
116
+ "lav": "lv",
117
+ "est": "et",
118
+ "fas": "fa",
119
+ "pes": "fa",
120
+ "urd": "ur",
121
+ "tam": "ta",
122
+ "tgl": "tl",
123
+ "fil": "tl",
124
+ "isl": "is",
125
+ "eus": "eu",
126
+ "glg": "gl",
127
+ }
128
+
129
+ # FrequencyWords filename stem on HermitDave/FrequencyWords content/2018/
130
+ FW_FILES: dict[str, str] = {
131
+ "en": "en_50k.txt",
132
+ "es": "es_50k.txt",
133
+ "fr": "fr_50k.txt",
134
+ "de": "de_50k.txt",
135
+ "it": "it_50k.txt",
136
+ "pt": "pt_50k.txt",
137
+ "nl": "nl_50k.txt",
138
+ "ru": "ru_50k.txt",
139
+ "pl": "pl_50k.txt",
140
+ "sv": "sv_50k.txt",
141
+ "cs": "cs_50k.txt",
142
+ "ro": "ro_50k.txt",
143
+ "hu": "hu_50k.txt",
144
+ "tr": "tr_50k.txt",
145
+ "uk": "uk_50k.txt",
146
+ "fi": "fi_50k.txt",
147
+ "da": "da_50k.txt",
148
+ "el": "el_50k.txt",
149
+ "bg": "bg_50k.txt",
150
+ "hr": "hr_50k.txt",
151
+ "sk": "sk_50k.txt",
152
+ "nb": "no_50k.txt",
153
+ "ca": "ca_50k.txt",
154
+ "id": "id_50k.txt",
155
+ "vi": "vi_50k.txt",
156
+ "ar": "ar_50k.txt",
157
+ "he": "he_50k.txt",
158
+ "hi": "hi_50k.txt",
159
+ "ko": "ko_50k.txt",
160
+ "ja": "ja_50k.txt",
161
+ "zh": "zh_50k.txt",
162
+ "fa": "fa_50k.txt",
163
+ "tl": "tl_50k.txt",
164
+ }
165
+
166
+ FW_BASE = "https://raw.githubusercontent.com/hermitdave/FrequencyWords/master/content/2018/"
167
+
168
+ _WORD_RE = re.compile(r"[^\W\d_]+", re.UNICODE)
169
+
170
+
171
+ def freq_code_for(lang: str, iso_639_3: str | None = None) -> str | None:
172
+ key = (lang or "").strip().casefold()
173
+ if key in LANG_TO_CODE:
174
+ return LANG_TO_CODE[key]
175
+ iso = (iso_639_3 or "").strip().casefold()
176
+ if iso in LANG_TO_CODE:
177
+ return LANG_TO_CODE[iso]
178
+ # wordfreq uses 2-letter codes mostly
179
+ if len(key) == 2 and key.isalpha():
180
+ return key
181
+ return None
182
+
183
+
184
+ def _norm_term(term: str) -> str:
185
+ t = (term or "").strip().casefold()
186
+ if t.startswith("*"):
187
+ t = t[1:]
188
+ return t
189
+
190
+
191
+ @lru_cache(maxsize=64)
192
+ def _wordfreq_ranks(code: str) -> dict[str, tuple[float, int]] | None:
193
+ try:
194
+ from wordfreq import get_frequency_dict, zipf_frequency, available_languages
195
+ except ImportError:
196
+ return None
197
+ langs = available_languages(wordlist="best")
198
+ if code not in langs:
199
+ # try large
200
+ langs = available_languages(wordlist="large")
201
+ wordlist = "large" if code in langs else None
202
+ if wordlist is None:
203
+ return None
204
+ else:
205
+ wordlist = "best"
206
+ freq_dict = get_frequency_dict(code, wordlist=wordlist)
207
+ ranked = sorted(freq_dict.items(), key=lambda kv: -kv[1])
208
+ out: dict[str, tuple[float, int]] = {}
209
+ for i, (w, _f) in enumerate(ranked, start=1):
210
+ # zipf_frequency is authoritative on the Zipf scale
211
+ z = float(zipf_frequency(w, code, wordlist=wordlist))
212
+ out[w] = (z, i)
213
+ return out
214
+
215
+
216
+ @lru_cache(maxsize=64)
217
+ def _ranked_file_map(path: str) -> dict[str, tuple[float, int]]:
218
+ """Ranked word list → (pseudo-zipf, rank). Zipf ≈ 7.5 - log10(rank)."""
219
+ out: dict[str, tuple[float, int]] = {}
220
+ p = Path(path)
221
+ if not p.exists():
222
+ return out
223
+ with p.open(encoding="utf-8", errors="ignore") as fh:
224
+ rank = 0
225
+ for line in fh:
226
+ line = line.strip()
227
+ if not line:
228
+ continue
229
+ # FrequencyWords: "word count" ; fasttext vocab dump: "word" or "word freq"
230
+ parts = line.split()
231
+ if not parts:
232
+ continue
233
+ w = parts[0].casefold()
234
+ if not w or w.startswith("#"):
235
+ continue
236
+ rank += 1
237
+ zipf = max(0.0, 7.5 - math.log10(rank))
238
+ out.setdefault(w, (zipf, rank))
239
+ if rank >= 1_000_000:
240
+ break
241
+ return out
242
+
243
+
244
+ def ensure_frequencywords(code: str) -> Path | None:
245
+ fname = FW_FILES.get(code)
246
+ if not fname:
247
+ return None
248
+ FREQ_DIR.mkdir(parents=True, exist_ok=True)
249
+ dest = FREQ_DIR / fname
250
+ if dest.exists() and dest.stat().st_size > 0:
251
+ return dest
252
+ url = FW_BASE + fname
253
+ try:
254
+ import httpx
255
+
256
+ with httpx.Client(timeout=60.0, follow_redirects=True) as client:
257
+ r = client.get(url)
258
+ r.raise_for_status()
259
+ dest.write_bytes(r.content)
260
+ return dest
261
+ except Exception:
262
+ return None
263
+
264
+
265
+ def _fasttext_path(code: str) -> Path | None:
266
+ for name in (f"{code}.txt", f"{code}_freq.txt", f"cc.{code}.txt"):
267
+ p = FASTTEXT_DIR / name
268
+ if p.exists():
269
+ return p
270
+ return None
271
+
272
+
273
+ def lookup_frequency(term: str, lang: str, iso_639_3: str | None = None) -> dict:
274
+ """Return zipf, rank (1=most frequent), and source. Unknown → zipf 0, rank 0."""
275
+ code = freq_code_for(lang, iso_639_3)
276
+ norm = _norm_term(term)
277
+ empty = {"zipf": 0.0, "rank": 0, "source": None, "code": code}
278
+ if not code or not norm:
279
+ return empty
280
+
281
+ # 1) wordfreq
282
+ wf = _wordfreq_ranks(code)
283
+ if wf is not None:
284
+ hit = wf.get(norm)
285
+ if hit is None:
286
+ # try simple token
287
+ m = _WORD_RE.search(norm)
288
+ if m:
289
+ hit = wf.get(m.group(0))
290
+ if hit is not None:
291
+ return {"zipf": hit[0], "rank": hit[1], "source": "wordfreq", "code": code}
292
+
293
+ # 2) optional FastText ranked vocab dir
294
+ ft = _fasttext_path(code)
295
+ if ft is not None:
296
+ mp = _ranked_file_map(str(ft))
297
+ hit = mp.get(norm)
298
+ if hit is not None:
299
+ return {"zipf": hit[0], "rank": hit[1], "source": "fasttext", "code": code}
300
+
301
+ # 3) FrequencyWords (OpenSubtitles) popular-usage fallback
302
+ fw = ensure_frequencywords(code)
303
+ if fw is not None:
304
+ mp = _ranked_file_map(str(fw))
305
+ hit = mp.get(norm)
306
+ if hit is not None:
307
+ return {"zipf": hit[0], "rank": hit[1], "source": "frequencywords", "code": code}
308
+
309
+ return empty
310
+
311
+
312
+ def annotate_nodes(
313
+ terms: list[str],
314
+ langs: list[str],
315
+ iso_by_lang: dict[str, str | None],
316
+ ) -> tuple["object", "object"]:
317
+ """Build per-node zipf (float32) and rank (uint32) arrays."""
318
+ import numpy as np
319
+
320
+ n = len(terms)
321
+ zipf = np.zeros(n, dtype=np.float32)
322
+ rank = np.zeros(n, dtype=np.uint32)
323
+ # Group by frequency code to reuse cached maps
324
+ by_code: dict[str, list[int]] = {}
325
+ for i, (t, lang) in enumerate(zip(terms, langs)):
326
+ code = freq_code_for(lang, iso_by_lang.get(lang))
327
+ if not code:
328
+ continue
329
+ by_code.setdefault(code, []).append(i)
330
+
331
+ for code, idxs in by_code.items():
332
+ # Warm caches once per code
333
+ sample_lang = langs[idxs[0]]
334
+ sample_iso = iso_by_lang.get(sample_lang)
335
+ for i in idxs:
336
+ info = lookup_frequency(terms[i], langs[i], iso_by_lang.get(langs[i]) or sample_iso)
337
+ zipf[i] = info["zipf"]
338
+ rank[i] = info["rank"]
339
+ return zipf, rank
backend/mmap_store.py CHANGED
@@ -25,6 +25,8 @@ MMAP_FILES = {
25
  "lang_glotto": ("lang_glotto.u16", np.uint16),
26
  "lang_lat": ("lang_lat.f32", np.float32),
27
  "lang_lon": ("lang_lon.f32", np.float32),
 
 
28
  }
29
 
30
 
@@ -64,7 +66,17 @@ def load_graph(index_dir: Path, copy: bool = False) -> dict[str, np.ndarray]:
64
  """Load mmap arrays. If copy=True, materialize into RAM (tests / tiny graphs)."""
65
  d = mmap_dir(index_dir)
66
  out: dict[str, np.ndarray] = {}
 
67
  for key, (fname, dtype) in MMAP_FILES.items():
68
- arr = open_array(d / fname, dtype)
 
 
 
 
 
 
 
69
  out[key] = np.array(arr) if copy else arr
 
 
70
  return out
 
25
  "lang_glotto": ("lang_glotto.u16", np.uint16),
26
  "lang_lat": ("lang_lat.f32", np.float32),
27
  "lang_lon": ("lang_lon.f32", np.float32),
28
+ "freq_zipf": ("freq_zipf.f32", np.float32),
29
+ "freq_rank": ("freq_rank.u32", np.uint32),
30
  }
31
 
32
 
 
66
  """Load mmap arrays. If copy=True, materialize into RAM (tests / tiny graphs)."""
67
  d = mmap_dir(index_dir)
68
  out: dict[str, np.ndarray] = {}
69
+ n_nodes = None
70
  for key, (fname, dtype) in MMAP_FILES.items():
71
+ path = d / fname
72
+ if not path.exists():
73
+ # Backward compatible: older indexes without frequency arrays.
74
+ if key in ("freq_zipf", "freq_rank") and n_nodes is not None:
75
+ out[key] = np.zeros(n_nodes, dtype=dtype)
76
+ continue
77
+ raise FileNotFoundError(path)
78
+ arr = open_array(path, dtype)
79
  out[key] = np.array(arr) if copy else arr
80
+ if key == "lang_code":
81
+ n_nodes = len(out[key])
82
  return out
backend/models.py CHANGED
@@ -32,6 +32,10 @@ class NodePredicate(BaseModel):
32
  phonemes_have: list[str] = Field(default_factory=list)
33
  phonemes_lack: list[str] = Field(default_factory=list)
34
  require_tone: bool | None = None
 
 
 
 
35
  keep_unknown: bool = False
36
 
37
 
 
32
  phonemes_have: list[str] = Field(default_factory=list)
33
  phonemes_lack: list[str] = Field(default_factory=list)
34
  require_tone: bool | None = None
35
+ # Popularity within language (modern lects). Zipf ~3 rare … ~7 very common.
36
+ min_zipf: float | None = Field(default=None, ge=0.0, le=8.0)
37
+ # Keep leaves whose corpus rank is <= N (1 = most frequent). Orders of magnitude presets in UI.
38
+ max_rank: int | None = Field(default=None, ge=1, le=5_000_000)
39
  keep_unknown: bool = False
40
 
41
 
frontend/src/App.tsx CHANGED
@@ -70,11 +70,13 @@ function PredicateEditor({
70
  value,
71
  onChange,
72
  extra,
 
73
  }: {
74
  meta: Meta;
75
  value: NodePredicate;
76
  onChange: (p: NodePredicate) => void;
77
  extra?: ReactNode;
 
78
  }) {
79
  const langOpts = useMemo(
80
  () => meta.languages.slice(0, 2500).map((l) => ({ key: l.key, label: `${l.display || l.key} (${l.nodes})` })),
@@ -132,6 +134,43 @@ function PredicateEditor({
132
  <option value="yes">has tone</option>
133
  <option value="no">no tone</option>
134
  </select>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  <label className="field">WALS feature</label>
136
  <select
137
  value={value.wals[0]?.feature_id || ""}
@@ -415,6 +454,8 @@ export default function App() {
415
  const chipCount =
416
  leaf.languages.length +
417
  leaf.families.length +
 
 
418
  pathNode.languages.length +
419
  pathNode.families.length +
420
  pathRelAny.length +
@@ -565,7 +606,7 @@ export default function App() {
565
  <p style={{ color: "var(--muted)", fontSize: 13 }}>
566
  Matching words in the filtered language stay visible even when they have further descendants.
567
  </p>
568
- <PredicateEditor meta={meta} value={leaf} onChange={setLeaf} />
569
  </div>
570
  )}
571
  {filterTab === "path" && (
@@ -753,6 +794,10 @@ export default function App() {
753
  <dd>{selected.macroarea || "—"}</dd>
754
  <dt>Glottocode</dt>
755
  <dd>{selected.glottocode || "—"}</dd>
 
 
 
 
756
  </dl>
757
  {selected.kind === "word" && (
758
  <div className="row">
 
70
  value,
71
  onChange,
72
  extra,
73
+ showPopularity = false,
74
  }: {
75
  meta: Meta;
76
  value: NodePredicate;
77
  onChange: (p: NodePredicate) => void;
78
  extra?: ReactNode;
79
+ showPopularity?: boolean;
80
  }) {
81
  const langOpts = useMemo(
82
  () => meta.languages.slice(0, 2500).map((l) => ({ key: l.key, label: `${l.display || l.key} (${l.nodes})` })),
 
134
  <option value="yes">has tone</option>
135
  <option value="no">no tone</option>
136
  </select>
137
+ {showPopularity && (
138
+ <>
139
+ <label className="field">Popularity in language</label>
140
+ <p style={{ color: "var(--muted)", fontSize: 12, margin: "0 0 6px" }}>
141
+ Modern lects via wordfreq (Zipf); fallback ranked lists for extra languages. Historical forms usually have no score.
142
+ </p>
143
+ <label className="field">Top N most frequent</label>
144
+ <select
145
+ value={value.max_rank ?? ""}
146
+ onChange={(e) => onChange({ ...value, max_rank: e.target.value === "" ? null : Number(e.target.value) })}
147
+ >
148
+ <option value="">any</option>
149
+ <option value={100}>top 100</option>
150
+ <option value={1000}>top 1,000</option>
151
+ <option value={10000}>top 10,000</option>
152
+ <option value={100000}>top 100,000</option>
153
+ <option value={1000000}>top 1,000,000</option>
154
+ </select>
155
+ <label className="field">
156
+ Min Zipf {value.min_zipf != null ? value.min_zipf.toFixed(1) : "off"}
157
+ </label>
158
+ <input
159
+ type="range"
160
+ min={0}
161
+ max={70}
162
+ step={1}
163
+ value={value.min_zipf == null ? 0 : Math.round(value.min_zipf * 10)}
164
+ onChange={(e) => {
165
+ const raw = Number(e.target.value);
166
+ onChange({ ...value, min_zipf: raw <= 0 ? null : raw / 10 });
167
+ }}
168
+ />
169
+ <p style={{ color: "var(--muted)", fontSize: 12, margin: "4px 0 0" }}>
170
+ Zipf ≈ 3 rare · 4 uncommon · 5 everyday · 6+ very common. 0 = no minimum.
171
+ </p>
172
+ </>
173
+ )}
174
  <label className="field">WALS feature</label>
175
  <select
176
  value={value.wals[0]?.feature_id || ""}
 
454
  const chipCount =
455
  leaf.languages.length +
456
  leaf.families.length +
457
+ (leaf.max_rank ? 1 : 0) +
458
+ (leaf.min_zipf != null ? 1 : 0) +
459
  pathNode.languages.length +
460
  pathNode.families.length +
461
  pathRelAny.length +
 
606
  <p style={{ color: "var(--muted)", fontSize: 13 }}>
607
  Matching words in the filtered language stay visible even when they have further descendants.
608
  </p>
609
+ <PredicateEditor meta={meta} value={leaf} onChange={setLeaf} showPopularity />
610
  </div>
611
  )}
612
  {filterTab === "path" && (
 
794
  <dd>{selected.macroarea || "—"}</dd>
795
  <dt>Glottocode</dt>
796
  <dd>{selected.glottocode || "—"}</dd>
797
+ <dt>Zipf</dt>
798
+ <dd>{selected.zipf != null ? selected.zipf : "—"}</dd>
799
+ <dt>Freq rank</dt>
800
+ <dd>{selected.freq_rank != null ? selected.freq_rank.toLocaleString() : "—"}</dd>
801
  </dl>
802
  {selected.kind === "word" && (
803
  <div className="row">
frontend/src/types.ts CHANGED
@@ -19,6 +19,8 @@ export interface NodePredicate {
19
  phonemes_have: string[];
20
  phonemes_lack: string[];
21
  require_tone: boolean | null;
 
 
22
  keep_unknown: boolean;
23
  }
24
 
@@ -65,6 +67,8 @@ export interface GraphNode {
65
  longitude?: number | null;
66
  glottocode?: string | null;
67
  iso_639_3?: string | null;
 
 
68
  count?: number;
69
  expand_key?: string;
70
  relation_mix?: Record<string, number>;
@@ -148,6 +152,8 @@ export function emptyPredicate(): NodePredicate {
148
  phonemes_have: [],
149
  phonemes_lack: [],
150
  require_tone: null,
 
 
151
  keep_unknown: false,
152
  };
153
  }
 
19
  phonemes_have: string[];
20
  phonemes_lack: string[];
21
  require_tone: boolean | null;
22
+ min_zipf?: number | null;
23
+ max_rank?: number | null;
24
  keep_unknown: boolean;
25
  }
26
 
 
67
  longitude?: number | null;
68
  glottocode?: string | null;
69
  iso_639_3?: string | null;
70
+ zipf?: number | null;
71
+ freq_rank?: number | null;
72
  count?: number;
73
  expand_key?: string;
74
  relation_mix?: Record<string, number>;
 
152
  phonemes_have: [],
153
  phonemes_lack: [],
154
  require_tone: null,
155
+ min_zipf: null,
156
+ max_rank: null,
157
  keep_unknown: false,
158
  };
159
  }
pipeline/build_index.py CHANGED
@@ -375,6 +375,14 @@ def main() -> None:
375
  raw = np.diff(rev_off)
376
  child_count = np.minimum(raw, 65535).astype(np.uint16)
377
 
 
 
 
 
 
 
 
 
378
  print("WALS / phonemes / cognates")
379
  wals_langs = pd.read_csv(RAW / "wals_languages.csv", low_memory=False)
380
  wals_codes = pd.read_csv(RAW / "wals_codes.csv")
@@ -579,6 +587,8 @@ def main() -> None:
579
  "lang_glotto": lang_glotto,
580
  "lang_lat": lang_lat,
581
  "lang_lon": lang_lon,
 
 
582
  },
583
  )
584
 
 
375
  raw = np.diff(rev_off)
376
  child_count = np.minimum(raw, 65535).astype(np.uint16)
377
 
378
+ print("word frequencies (wordfreq + popular-usage fallback)")
379
+ from backend.frequency import annotate_nodes
380
+
381
+ iso_by_lang = {r["lang_key"]: r.get("iso_639_3") for r in lang_rows}
382
+ freq_zipf, freq_rank = annotate_nodes(terms, langs, iso_by_lang)
383
+ known = int(np.count_nonzero(freq_rank))
384
+ print(f" frequency known for {known:,}/{n_nodes:,} nodes")
385
+
386
  print("WALS / phonemes / cognates")
387
  wals_langs = pd.read_csv(RAW / "wals_languages.csv", low_memory=False)
388
  wals_codes = pd.read_csv(RAW / "wals_codes.csv")
 
587
  "lang_glotto": lang_glotto,
588
  "lang_lat": lang_lat,
589
  "lang_lon": lang_lon,
590
+ "freq_zipf": freq_zipf,
591
+ "freq_rank": freq_rank,
592
  },
593
  )
594
 
requirements.txt CHANGED
@@ -1,3 +1,4 @@
 
1
  fastapi>=0.115.0
2
  uvicorn[standard]>=0.32.0
3
  numpy>=2.0.0
 
1
+ wordfreq>=3.1.0
2
  fastapi>=0.115.0
3
  uvicorn[standard]>=0.32.0
4
  numpy>=2.0.0