Files
scrabble-dictionary/tools/ru_stage2.py
T
Ilia Denisov 74ff3aefb6
build / dawg (pull_request) Successful in 6m4s
feat(ru): drop abbreviations from the Russian noun list
The list leaked abbreviations two ways: an OpenCorpora noun tagged Abbr without
Fixd (the declinable acronym ОГВЗ) slipped past the Abbr+Fixd seed filter, and the
РАН «(сокр.)» words (вуз, нэп, колхоз, гост, …) were kept on purpose by the old
policy.

Drop every abbreviation instead. A final pass in ru_stage2.py removes a word when
the orthographic note marks it «(сокр.)» (is_abbrev_note) or when every OpenCorpora
noun reading carries Abbr (oc_abbr_only — also the declinable огвз/заз the seed
filter missed). Homographs with a plain-noun reading survive (ваза «ВАЗ», рис).
Lexicalised words are rescued via manual_confirm.txt (колхоз, совхоз, рация,
спецназ, эсминец, дзот, дот, прораб, токамак, тэн, … and под — a real noun OC knows
only as ПОД); the bound prefix гос is vetoed in manual_reject.txt.

108 words removed from ru_scrabble / ru_erudit; en_sowpods unchanged. tools/README
documents the policy and that libmorph is required for a faithful rebuild.
2026-06-22 14:16:55 +02:00

490 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Stage 2 — the "brain" of the Russian Scrabble word-list pipeline.
It reads the Stage-1 base word list (built once by ruwords so the heavy PDF is not
re-parsed) together with the grammatical notes and the singular/variant structure, runs
the whole noun-selection logic in memory, and writes a minimal result:
sources/scrabble_ru/scrabble.txt — the working dictionary (common nouns, nom. sing.)
sources/scrabble_ru/undefined.txt — the ambiguous tail, left for manual review
(sources/scrabble_ru/all.txt is the Stage-1 base.) Every other bucket — adjectives, verbs,
the merged note-nouns, singulars, variants — stays in memory. Pass --dump to also write
them; pass --trace WORD to ask how a single word did or did not reach the dictionary.
Note: all.txt is a plain word list, so the grammatical notes, "ед." singulars and "и"
variants are read from the pdftotext output (slov.txt) and the Stage-1 side files; the
expensive PDF parse itself runs only once.
Sources, most authoritative first: OpenCorpora (mawo-pymorphy3), libmorph (libmorph_check),
and the orthographic dictionary's own notes. See tools/README.md.
Run: ru-venv/bin/python tools/ru_stage2.py [--dump] [--trace WORD]
"""
import argparse
import os
import re
import subprocess
HERE = os.path.dirname(os.path.abspath(__file__))
# The curated Russian word lists live in sources/scrabble_ru/ (this tool sits in tools/);
# the uncommitted pipeline intermediates (orfo/all/debug) are regenerated alongside them.
OUT_DIR = os.path.join(HERE, "..", "sources", "scrabble_ru")
SLOV = os.path.join(OUT_DIR, "orfo_dict_2025.txt") # committed pdftotext output (source of truth)
WL_FROM, WL_TO = 452, 168808 # 1-based inclusive bounds of the column word-list section
OC_CACHE = "/tmp/oc_nouns.txt"
LIBMORPH_BIN = os.path.join(HERE, "libmorph_check")
ALPHABET = "абвгдеёжзийклмнопрстуфхцчшщъыьэюя"
ORDER = {c: i for i, c in enumerate(ALPHABET)}
PROPER = {"Name", "Surn", "Patr", "Geox", "Orgn", "Trad"}
ABBR_NOUN = {"Abbr", "Fixd"} # indeclinable abbreviation marker (НДС, КПД, сельпо, …)
FUNCTION_POS = {"CONJ", "PRCL"} # conjunction / particle: a function word, never a Scrabble noun
LIBMORPH_NOUN_CODES = set(range(7, 22)) | {24} # 7..21 plus 24 (pluralia tantum)
ADJ_END = {"ая", "яя", "ое", "ее", "ье", "ья", "ьи"}
VERB3 = ("ет", "ёт", "ит", "ют", "ут", "ает", "яет", "ует", "уют", "нет", "жет", "чет")
GENPL = ("ов", "ёв", "ев", "ей")
def key(w):
return [ORDER.get(c, 99) for c in w]
def destress(s):
return "".join(c for c in s if ord(c) not in (0x0300, 0x0301)).lower()
def cyr_ok(w):
return 2 <= len(w) <= 15 and all(("а" <= c <= "я") or c == "ё" for c in w)
def load(p):
return [l.strip() for l in open(p, encoding="utf-8") if l.strip()] if os.path.exists(p) else []
def write(path, words):
os.makedirs(os.path.dirname(path), exist_ok=True)
open(path, "w", encoding="utf-8").write("\n".join(sorted(set(words), key=key)) + "\n")
import mawo_pymorphy3 # noqa: E402
M = mawo_pymorphy3.MorphAnalyzer()
D = M._dawg_dict
def oc_noun_lemmas():
"""Every common-noun lemma (nom. sing. / pluralia tantum) in OpenCorpora's words.dawg,
excluding indeclinable abbreviations (Abbr+Fixd, e.g. НДС, КПД). Such words are admitted
only when the orthographic dictionary attests them as nouns (see oc_abbr_fixd_only and the
Stage-2 loop); abbreviations carried by OpenCorpora alone (ст, ср, кпд, …) are dropped."""
gp, pt = D.get_paradigm, D.parse_tag_string
para0, tagc = {}, {}
def g0(pid):
r = para0.get(pid)
if r is None:
suf0, tag0, pre0 = gp(pid, 0)
_, gr = pt(tag0)
r = (pre0, suf0, gr)
para0[pid] = r
return r
def gt(pid, idx):
k = (pid, idx)
r = tagc.get(k)
if r is None:
suf, tag, pre = gp(pid, idx)
pos, gr = pt(tag)
r = (suf, pre, pos, gr)
tagc[k] = r
return r
out = set()
for word, rec in D.words_dawg.iteritems():
pid, idx = rec
suf, pre, pos, gr = gt(pid, idx)
if pos != "NOUN":
continue
pre0, suf0, gr0 = g0(pid)
if (PROPER & gr) or (PROPER & gr0):
continue
if ABBR_NOUN <= gr0: # indeclinable abbreviation: not seeded; the loop decides via the note
continue
stem = word[len(pre):len(word) - len(suf)] if suf else word[len(pre):]
out.add(pre0 + stem + suf0)
return {w for w in out if cyr_ok(w)}
def oc_status(word):
"""(is_common_noun, in_dictionary) for word, from OpenCorpora only."""
parses = D.get_word_parses(word)
if not parses:
return False, False
gp, pt = D.get_paradigm, D.parse_tag_string
for pid, idx in parses:
suf, tag, pre = gp(pid, idx)
pos, gr = pt(tag)
if pos == "NOUN":
_, tag0, _ = gp(pid, 0)
_, gr0 = pt(tag0)
if not (PROPER & gr or PROPER & gr0):
return True, True
return False, True
def oc_abbr_fixd_only(word):
"""True when OpenCorpora's only common-noun reading of word is an indeclinable
abbreviation (Abbr+Fixd) — e.g. сельпо, ндс, ан. Such a word is not admitted as a noun on
OpenCorpora's say-so; the Stage-2 loop hands it to the orthographic note (and the function-
word check) instead, so lexicalised nouns (сельпо, под, ска) survive while bare letter-
abbreviations (ндс, кпд) and proper/служебные homographs (ан, зато) do not."""
parses = D.get_word_parses(word)
if not parses:
return False
gp, pt = D.get_paradigm, D.parse_tag_string
has_noun = has_plain = False
for pid, idx in parses:
suf, tag, pre = gp(pid, idx)
pos, gr = pt(tag)
if pos != "NOUN":
continue
_, tag0, _ = gp(pid, 0)
_, gr0 = pt(tag0)
if PROPER & gr or PROPER & gr0:
continue
has_noun = True
if not (ABBR_NOUN <= gr0):
has_plain = True
return has_noun and not has_plain
def oc_function_word(word):
"""True when OpenCorpora reads word as a conjunction or particle — a function word that
shares a spelling with an abbreviation/proper noun (ан «ан нет», зато). It is never a
Scrabble noun even if the orthographic note looks noun-like (ан → «самолёт Антонова»)."""
gp, pt = D.get_paradigm, D.parse_tag_string
for pid, idx in (D.get_word_parses(word) or []):
if pt(gp(pid, idx)[1])[0] in FUNCTION_POS:
return True
return False
def oc_abbr_only(word):
"""True when every common-noun reading OpenCorpora gives word is an abbreviation (carries
the Abbr grammeme on its paradigm). Generalises oc_abbr_fixd_only past the Fixd
(indeclinable) requirement, so it also catches a *declinable* acronym that OpenCorpora
inflects through a full Abbr case paradigm without Fixd — огвз, заз — as well as the
indeclinable роно, тв, фио. A word with any non-Abbr noun reading (the homographs ваза
«ВАЗ», рис «РИС», под «ПОД»/под печи) is not abbreviation-only and is left alone."""
parses = D.get_word_parses(word)
if not parses:
return False
gp, pt = D.get_paradigm, D.parse_tag_string
has_noun = has_plain = False
for pid, idx in parses:
suf, tag, pre = gp(pid, idx)
pos, gr = pt(tag)
if pos != "NOUN":
continue
_, tag0, _ = gp(pid, 0)
_, gr0 = pt(tag0)
if PROPER & gr or PROPER & gr0:
continue
has_noun = True
if "Abbr" not in gr0:
has_plain = True
return has_noun and not has_plain
def is_abbrev_note(note):
"""True when the orthographic note marks an abbreviation («сокр.», e.g. вуз, нэп, колхоз)."""
return bool(note) and "сокр" in note
def libmorph_analyze(words):
"""Map each word to (known, noun_lemma, codes) per libmorph; noun_lemma is None when it
is not a common noun there. Empty result if the helper binary is not built."""
words = list(words)
if not words or not os.path.exists(LIBMORPH_BIN):
return {}
proc = subprocess.run([LIBMORPH_BIN], input="\n".join(words), capture_output=True, text=True)
out = {}
for w, line in zip(words, proc.stdout.split("\n")):
fields = line.split("\t")
known = fields[:1] == ["1"]
codes, noun_lemmas = set(), []
for field in fields[1:]:
code, _, lex = field.partition(":")
if code.isdigit():
codes.add(int(code))
if int(code) in LIBMORPH_NOUN_CODES:
noun_lemmas.append(lex)
lemma = (w if w in noun_lemmas else noun_lemmas[0]) if noun_lemmas else None
out[w] = (known, lemma, codes)
return out
def build_notes():
"""Map each headword (destressed, lowercased) to its grammatical note."""
def is_hw(ch):
o = ord(ch)
return (0x0430 <= o <= 0x044F) or (0x0410 <= o <= 0x042F) or o in (0x0401, 0x0451, 0x0300, 0x0301)
hmap = {}
lines = open(SLOV, encoding="utf-8").read().split("\n")
for l in lines[WL_FROM - 1:WL_TO]:
s = l.lstrip()
e = 0
for ch in s:
if is_hw(ch):
e += 1
else:
break
hw = destress(s[:e])
if hw and hw not in hmap:
hmap[hw] = destress(s[e:]).strip()
return hmap
def classify(w, note):
"""Coarse part of speech of an out-of-dictionary word from its PDF note."""
if note is None:
return "amb"
n = re.sub(r"\([^)]*\)", "", note).strip() # drop domain/etymology parentheticals
if "кр. ф" in n or "кр.ф" in n or "прич." in n or "прил." in n:
return "adj"
ends = re.findall(r"-([а-яё]+)", n)
if any(e in ADJ_END for e in ends):
return "adj"
if "сов." in n or "несов." in n or "безл." in n:
return "verb"
if w.endswith("ся"): # reflexive: no Russian noun ends in -ся
return "verb"
if any(e.endswith(VERB3) for e in ends) and not any(m in n for m in ("ед.", "тв.", "род.", "м.", "ж.", "с.")):
return "verb"
if n == "" and w.endswith(("ый", "ий", "ой", "ая", "ое", "ые", "ие", "яя", "ее")):
return "adj"
if "нескл" in n:
return "noun" if any(g in n for g in ("м.", "ж.", "с.", "мн.")) else "amb"
if ends:
return "noun"
if n == "" and w.endswith(("ать", "ять", "еть", "ить", "оть", "уть", "ыть", "ти", "чь")):
return "verb"
return "amb"
def singular(w, note):
"""Nominative singular of a noun headword from the PDF note (authoritative) or, for a
plural headword without an explicit singular, the mawo lemma; pluralia tantum kept."""
n = note or ""
full = re.search(r"ед\.\s+([а-яё]+)", n)
if full:
return full.group(1)
suf = re.search(r"ед\.\s+-([а-яё]+)", n)
if suf:
s = suf.group(1)
i = w.rfind(s[0])
return w[:i] + s if i > 0 else w
ends = re.findall(r"-([а-яё]+)", re.sub(r"\([^)]*\)", "", n))
if ends and ends[0].endswith(GENPL):
for p in M.parse(w):
if str(p.tag.POS) == "NOUN":
return p.normal_form
return w
return w
def build():
"""Run the whole pipeline in memory. Returns the result sets plus a `fate` map giving
every word's outcome, so a word's path can be traced or the buckets dumped."""
oc = set(load(OC_CACHE)) or oc_noun_lemmas()
if not os.path.exists(OC_CACHE):
write(OC_CACHE, oc)
hmap = build_notes()
all_words = load(os.path.join(OUT_DIR, "all.txt"))
ed_nouns = set(load("/tmp/ru_singulars.txt"))
pairs = [tuple(p) for l in load("/tmp/ru_variants.txt") if len(p := l.split("\t")) == 2]
pdf = [w for w in all_words if cyr_ok(w)]
lm = libmorph_analyze(pdf)
def to_singular(w):
s = singular(w, hmap.get(w))
return s if cyr_ok(s) else w
fate = {}
scrabble = set(oc)
adj, verb, amb = [], [], []
for w in pdf:
if oc_abbr_fixd_only(w):
# Indeclinable abbreviation in OpenCorpora: do not trust OC's noun verdict. Drop it
# if OC also reads it as a function word (ан, зато — служебное/пропер); otherwise let
# the orthographic note decide, keeping lexicalised nouns (сельпо, под, ска) and
# dropping the rest (кпд, чп, гор, …).
if oc_function_word(w):
fate[w] = "отброшено: служебное слово (CONJ/PRCL в OpenCorpora)"
elif classify(w, hmap.get(w)) == "noun":
s = to_singular(w)
scrabble.add(s)
fate[w] = "scrabble: несклон. аббрев.-сущ. по помете орфословаря" + ("" if s == w else f"{s}")
else:
fate[w] = "отброшено: аббревиатура без подтверждающей пометы орфословаря"
continue
oc_noun, oc_known = oc_status(w)
if oc_noun:
# A common-noun reading already put w in the seed (scrabble = set(oc)) — unless its
# lemma is a different word, i.e. w is an inflected form (ан = род. мн. от «ана»);
# such a form is not a headword and stays out.
fate[w] = ("scrabble: сущ. по OpenCorpora" if w in scrabble
else "отброшено: словоформа OpenCorpora (лемма — другое слово)")
continue
lm_known, lm_lemma, _ = lm.get(w, (False, None, frozenset()))
if lm_lemma is not None:
s = lm_lemma if cyr_ok(lm_lemma) else to_singular(w)
scrabble.add(s)
fate[w] = "scrabble: сущ. по libmorph" + ("" if s == w else f"{s}")
continue
if oc_known or lm_known:
fate[w] = "отброшено: словарь знает как не-существительное"
continue
if w in ed_nouns:
scrabble.add(w)
fate[w] = "scrabble: ед.ч. по помете «ед.»"
continue
c = classify(w, hmap.get(w))
if c == "noun":
s = to_singular(w)
scrabble.add(s)
fate[w] = "scrabble: сущ. по помете орфословаря" + ("" if s == w else f"{s}")
elif c == "adj":
adj.append(w)
fate[w] = "отброшено: прилагательное (помета орфословаря)"
elif c == "verb":
verb.append(w)
fate[w] = "отброшено: глагол (помета орфословаря)"
else:
amb.append(w)
fate[w] = "undefined: неоднозначное (нет в словарях, помета не определяет)"
# Manual confirmations: nouns the maintainer approved from the undefined tail.
for w in load(os.path.join(OUT_DIR, "manual_confirm.txt")):
if cyr_ok(w):
scrabble.add(w)
fate[w] = "scrabble: подтверждено вручную (manual_confirm.txt)"
# Variant rescue: a word joined by "и" to a confirmed noun is itself a noun.
pending = set(amb) - scrabble
changed = True
while changed:
changed = False
for a, b in pairs:
for x, y in ((a, b), (b, a)):
if x in scrabble and y in pending:
scrabble.add(y)
pending.discard(y)
fate[y] = f"scrabble: вариант от «{x}» (через «и»)"
changed = True
# Abbreviations are not Scrabble words. Two independent signals catch them: the orthographic
# note marks «(сокр.: …)» (вуз, нэп, колхоз, гост, …); and OpenCorpora tags acronyms Abbr —
# both the indeclinable (роно, тв, фио) and the declinable ones the Abbr+Fixd seed filter
# misses (огвз, заз, гулаг). Drop every such word, except the lexicalised ones the maintainer
# rescues in manual_confirm.txt (колхоз, совхоз, рация, спецназ, … and под — a real noun
# OpenCorpora knows only as the abbreviation ПОД). The homographs that are real words with a
# mere abbreviation reading (ваза, рис) survive — oc_abbr_only requires *all* noun readings
# to be Abbr. Subtracted before the manual-reject veto.
confirm = {w for w in load(os.path.join(OUT_DIR, "manual_confirm.txt")) if cyr_ok(w)}
abbrev = {w for w in scrabble if w not in confirm and (is_abbrev_note(hmap.get(w)) or oc_abbr_only(w))}
scrabble -= abbrev
for w in abbrev:
fate[w] = "отброшено: аббревиатура (помета «сокр.» орфословаря или только Abbr-чтения в OpenCorpora)"
# Manual rejections: words the maintainer vetoed — substantivized-adjective false nouns
# a dictionary misreads as nouns (нёбный, акцизный, …). Subtracted last, so it overrides
# every admission path above (OC seed / libmorph / note / manual_confirm / variant).
reject = {w for w in load(os.path.join(OUT_DIR, "manual_reject.txt")) if cyr_ok(w)}
scrabble -= reject
for w in reject:
fate[w] = "отброшено: ручной запрет (manual_reject.txt)"
undefined = [w for w in amb if w not in scrabble and w not in reject]
return {
"oc": oc, "scrabble": scrabble, "undefined": undefined,
"adjectives": adj, "verbs": verb, "singulars": ed_nouns,
"fate": fate, "all": set(all_words), "hmap": hmap,
}
def trace(word, r):
"""A detailed, per-signal audit of how WORD did or did not reach the dictionary: its
Stage-1 (all.txt) membership, every OpenCorpora reading, libmorph, the orthographic note
with its classify verdict, and the final outcome. Used by `--trace WORD`."""
w = destress(word)
note = r["hmap"].get(w)
parses = D.get_word_parses(w) or []
gp, pt = D.get_paradigm, D.parse_tag_string
pos = sorted({pt(gp(pid, idx)[1])[0] for pid, idx in parses})
oc_noun, _ = oc_status(w)
out = [f"{word}{'В СЛОВАРЕ' if w in r['scrabble'] else 'НЕ в словаре'}"]
out.append(f" Stage-1 / all.txt (строчный заголовок РАН): {'да' if w in r['all'] else 'нет'}")
if parses:
kind = ("только несклон. аббрев. (Abbr+Fixd)" if oc_abbr_fixd_only(w) else
"обычное сущ." if oc_noun else
"не существительное")
out.append(f" OpenCorpora: части речи {pos}; как сущ. — {kind}; "
f"служебное (CONJ/PRCL): {'да' if oc_function_word(w) else 'нет'}")
else:
out.append(" OpenCorpora: нет в словаре")
lm = libmorph_analyze([w]).get(w)
if lm:
known, lemma, _ = lm
out.append(" libmorph: " + (f"сущ. → {lemma}" if lemma else ("известно, не сущ." if known else "нет")))
else:
out.append(" libmorph: helper отсутствует — в анализе не участвует")
out.append(" Орфословарь РАН: " + (f"помета «{note}» → classify={classify(w, note)}"
if note is not None else "нет заголовка"))
reason = r["fate"].get(w)
if reason is None:
if w in r["scrabble"]:
reason = "лексикон OpenCorpora (обычное сущ.)"
elif oc_abbr_fixd_only(w) and w not in r["all"]:
reason = "исключено из посева: несклон. аббрев. (Abbr+Fixd), нет в all.txt"
elif not parses and w not in r["all"]:
reason = "нет ни в OpenCorpora, ни в all.txt"
else:
reason = "не прошло ни один путь отбора"
out.append(f" ИТОГ: {reason}")
return "\n".join(out)
def main():
ap = argparse.ArgumentParser(description="Stage 2 brain: build the noun dictionary, trace a word, or dump buckets.")
ap.add_argument("--dump", action="store_true", help="also write the in-memory buckets (adjectives, verbs, singulars, variants, fate)")
ap.add_argument("--trace", metavar="WORD", help="report how WORD did or did not reach the dictionary, then exit")
args = ap.parse_args()
r = build()
if args.trace:
print(trace(args.trace, r))
return
write(os.path.join(OUT_DIR, "scrabble.txt"), r["scrabble"])
print(f"=> sources/scrabble_ru/scrabble.txt {len(r['scrabble'])}")
print(f" undefined kept in memory: {len(set(r['undefined']))} (use --dump to write it)")
if args.dump:
write(os.path.join(OUT_DIR, "undefined.txt"), r["undefined"])
write(os.path.join(OUT_DIR, "adjectives.txt"), r["adjectives"])
write(os.path.join(OUT_DIR, "verbs.txt"), r["verbs"])
write(os.path.join(OUT_DIR, "singulars.txt"), r["singulars"])
fate_path = os.path.join(OUT_DIR, "fate.tsv")
os.makedirs(OUT_DIR, exist_ok=True)
with open(fate_path, "w", encoding="utf-8") as f:
for w in sorted(r["fate"], key=key):
f.write(f"{w}\t{r['fate'][w]}\n")
print(f" dumped: undefined.txt ({len(set(r['undefined']))}), adjectives.txt, verbs.txt, singulars.txt, fate.tsv")
if __name__ == "__main__":
main()