ukvocab 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ # Licenses
2
+
3
+ ## The combined work: CC-BY-NC-SA 4.0
4
+
5
+ This package as a whole is distributed under the Creative Commons
6
+ Attribution-NonCommercial-ShareAlike 4.0 International license
7
+ (https://creativecommons.org/licenses/by-nc-sa/4.0/).
8
+
9
+ Why NonCommercial and share-alike: the per-lemma CEFR levels come from the
10
+ Ukrainian Vocabulary Profile (PULS), which its authors license CC BY-NC-SA 4.0
11
+ (decided 2026-09-24) and which is redistributed here with their permission.
12
+ Those levels are in the validated overlay, in every row of the crosswalk, in
13
+ the `puls_level` column of the two Borodin tables, and in the `related_level`
14
+ column of the aspect table. NonCommercial and share-alike propagate to a
15
+ combined work, so the package carries both. They are the one NonCommercial
16
+ component; nothing else NonCommercial is shipped. The frequency list and the
17
+ two Borodin minimums are CC-BY 4.0 on their own and remain so, and the
18
+ government and collocation tables are CC-BY-SA 4.0; each dataset's own license
19
+ is recorded in `ukvocab.sources()` and below, so a downstream user who drops
20
+ the PULS level columns owes attribution (and, for the corpus tables,
21
+ share-alike) and nothing more.
22
+
23
+ Cite the profile as its authors ask: Профіль української лексики (ПУЛЬС) / О. Синчак, В. Старко, М. Бурак, М. Свистун та ін. Львів: Школа української мови та культури УКУ, 2026.
24
+
25
+ ## Per dataset
26
+
27
+ | Dataset | License | Attribution |
28
+ |---|---|---|
29
+ | `frequency` (top-12,000 lemma list) | CC-BY 4.0 | Derived aggregate counts from the UberText 2.0 lemma frequency dictionary, lang-uk. Chaplynskyi, D. (2023). Introducing UberText 2.0: A Corpus of Modern Ukrainian at Scale. Proc. 2nd UNLP, ACL. https://lang.org.ua/en/ubertext/ |
30
+ | `validated_cefr` (PULS CEFR overlay) | CC-BY-NC-SA 4.0 | Ukrainian Vocabulary Profile (PULS): Synchak, O., Starko, V., Burak, M., Svystun, M. https://puls.peremova.org/ (eLex 2025). Per-lemma levels redistributed with the authors' permission (V. Starko, July 2026); CC BY-NC-SA 4.0 at the authors' decision (O. Synchak, 2026-09-24), attribution required. Snapshot 2026-07-23; the profile is not final. |
31
+ | `borodin_a1` (A1 lexical minimum) | CC-BY 4.0 | Borodin, K. and Turkevych, O. 1000 i 1 slovo. Leksychnyi minimum z ukrainskoi movy yak inozemnoi. Riven A1. Slavic Language Education 3, LANGUAGE-LAB, Humboldt-Universitat zu Berlin. https://doi.org/10.18452/28236. Machine-readable list provided by the authors, 2026-07-27. The `puls_level` column is CC-BY-NC-SA (PULS). |
32
+ | `borodin_a2` (A2 lexical minimum) | CC-BY 4.0 | Borodin, K. and Lazarenko, O. (2026). 1000 i 1 slovo. Riven A2. Europa-Universitat Viadrina Frankfurt (Oder). https://doi.org/10.11584/opus4-1448. The `puls_level` column is CC-BY-NC-SA (PULS). |
33
+ | `crosswalk` (minimums against the profile) | CC-BY-NC-SA 4.0 | Lawrence, J. (2026), over the two Borodin volumes (CC-BY 4.0), the PULS profile (CC BY-NC-SA 4.0, by permission) and UberText 2.0 ranks (CC-BY 4.0). |
34
+ | `b1_aspect` (aspect relations) | CC-BY-NC-SA 4.0 | Levels: PULS, by permission, CC BY-NC-SA 4.0. Aspect partners: Wiktionary via kaikki.org, CC-BY-SA 3.0. Counts: UberText 2.0, CC-BY 4.0. |
35
+ | `b1_government` (case government) | CC-BY-SA 4.0 | Counts over the UberText 2.0 Wikipedia subcorpus (lang-uk, CC-BY 4.0); derived statistics CC-BY-SA 4.0. |
36
+ | `b1_collocations` (logDice collocations) | CC-BY-SA 4.0 | Same corpus and terms as `b1_government`. |
37
+
38
+ ## Not in this package
39
+
40
+ Predicted CEFR levels (the site's gap-fill model; predictions are never merged
41
+ into the validated overlay), any VESUM-derived lexicon or wordform data
42
+ (CC BY-NC-SA), the UniMorph derivational families (CC BY-NC 4.0), the
43
+ Wikipedia example sentences, and the Wiktionary paradigm tables. If you need
44
+ any of them, they are on the site with their own terms.
45
+
46
+ ## The code
47
+
48
+ The Python code in this package (`src/ukvocab/*.py`) is released under the
49
+ same CC-BY-NC-SA 4.0 terms as the data, to keep one license on the wheel.
ukvocab-0.3.0/PKG-INFO ADDED
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.4
2
+ Name: ukvocab
3
+ Version: 0.3.0
4
+ Summary: The published Ukrainian vocabulary data from jakelawrence.xyz/research/ukrainian-frequency, offline: the top-12,000 lemma frequency list, the validated PULS CEFR overlay, the Borodin A1 and A2 lexical minimums, their crosswalk, and the B1 enrichment tables.
5
+ Author: Jake Lawrence
6
+ License-Expression: CC-BY-NC-SA-4.0
7
+ Project-URL: Homepage, https://www.jakelawrence.xyz/research/ukrainian-frequency
8
+ Project-URL: Source, https://github.com/jake0lawrence/jakelawrence.xyz/tree/main/packages/ukvocab
9
+ Project-URL: Data, https://www.jakelawrence.xyz/downloads/ukrainian-frequency/
10
+ Keywords: ukrainian,vocabulary,frequency list,CEFR,lexical minimum,language learning,corpus
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Education
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Natural Language :: Ukrainian
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Topic :: Education
18
+ Classifier: Topic :: Text Processing :: Linguistic
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSES.md
22
+ Requires-Dist: pyarrow>=14
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest>=7; extra == "test"
25
+ Dynamic: license-file
26
+
27
+ # ukvocab
28
+
29
+ The published Ukrainian vocabulary data from
30
+ [jakelawrence.xyz/research/ukrainian-frequency](https://www.jakelawrence.xyz/research/ukrainian-frequency),
31
+ installable and offline: the top-12,000 lemma frequency list over UberText 2.0,
32
+ the validated PULS CEFR overlay (A1 to C1), the Borodin A1 and A2 lexical
33
+ minimums, the crosswalk between the minimums and the profile, and the B1
34
+ enrichment tables (aspect relations, case government, collocations).
35
+
36
+ ```python
37
+ import ukvocab
38
+ ukvocab.lookup("п'ять")["level"] # 'A1', with rank, source and enrichment beside it
39
+ ukvocab.coverage(["в", "школа", "йти"], "A2")["coverage"] # share of tokens a validated-A2 learner knows
40
+ ```
41
+
42
+ ## What you get
43
+
44
+ | function | returns |
45
+ |---|---|
46
+ | `lookup(lemma, pos=None)` | one lemma: `level`, `level_source`, `rank`, the frequency figures, every other reading of the spelling, minimum membership, the crosswalk class, and the B1 `enrichment` (aspect partners, government, collocations); `None` when no table knows the string |
47
+ | `levels(lemmas)` | batch: input string to `{level, level_source, rank, pos, source}` |
48
+ | `coverage(lemmas, known_set)` | token coverage of a lemmatized text against a known set: a level (`"A2"` means every validated lemma at or below A2), `"borodin-a1"` / `"borodin-a2"`, an int N (top N of the list), or your own set of lemmas; reports the unknowns by frequency |
49
+ | `sources()` | version, snapshot date, license, attribution, citation and SHA-256 per dataset, the combined license and why, and what is deliberately not shipped |
50
+
51
+ Every return value carries the dataset, version and snapshot it came from.
52
+ Data is Parquet, loaded lazily on first use, never on import. The only
53
+ dependency is `pyarrow`.
54
+
55
+ Two things the package does not do, on purpose. It does not lemmatize: pass
56
+ lemmas in the UberText / VESUM convention (infinitive for verbs, nominative
57
+ singular for nouns); the site's `POST /api/lemmatize` is the hosted lemmatizer
58
+ matched to this list. And it ships no predicted CEFR levels: only the profile's
59
+ validated levels are here, and a lemma the profile does not cover gets
60
+ `level: None` rather than a guess.
61
+
62
+ ## Provenance and honesty
63
+
64
+ The data tier is generated from the site's published download files by
65
+ `scripts/ukrainian-frequency/build-package.py` in the source repository and is
66
+ never edited by hand; the tests assert that every table's row count and hash
67
+ match the published file exactly. `sources()` is the manifest that build
68
+ wrote. The PULS overlay is a dated snapshot (2026-07-23) of a profile that is
69
+ still being extended, redistributed with its authors' permission. The join
70
+ onto the frequency list is part-of-speech aware; ten declared cross-tag rules
71
+ recover the words the two tag conventions merely label differently, and each
72
+ row's `join` field says how it matched, so the strict join is one filter away.
73
+
74
+ ## License
75
+
76
+ The combined package is **CC-BY-NC-SA 4.0**, because the PULS levels are
77
+ NonCommercial and share-alike at the authors' decision (2026-09-24) and they run
78
+ through most tables. The frequency list and the two Borodin minimums are CC-BY
79
+ 4.0 on their own, and the government and collocation tables CC-BY-SA 4.0; drop
80
+ the PULS level columns and NonCommercial does not apply. `LICENSES.md` has the
81
+ per-dataset terms and every attribution.
82
+
83
+ ## Citation
84
+
85
+ ```
86
+ Lawrence, J. (2026). ukvocab: the Ukrainian Frequency datasets (top-12,000 lemma
87
+ list, validated PULS CEFR overlay, Borodin A1/A2 minimums, crosswalk, B1
88
+ enrichment tables), version 0.2.0. jakelawrence.xyz/research/ukrainian-frequency.
89
+
90
+ Upstream: Chaplynskyi, D. (2023). Introducing UberText 2.0: A Corpus of Modern
91
+ Ukrainian at Scale. Proc. 2nd UNLP, ACL. | Synchak, O., Starko, V., Burak, M.,
92
+ Svystun, M. (2025). Corpus-Based Vocabulary Profiling for Ukrainian: From
93
+ Lexical Analysis to CEFR. eLex 2025; PULS, puls.peremova.org. | Borodin, K. and
94
+ Turkevych, O. 1000 i 1 slovo, A1. https://doi.org/10.18452/28236 | Borodin, K.
95
+ and Lazarenko, O. (2026). 1000 i 1 slovo, A2. https://doi.org/10.11584/opus4-1448
96
+ ```
97
+
98
+ Archived, DOI-minted copies of each dataset are listed in `sources()` under
99
+ `conceptDoi` once they exist; cite the concept DOI for the dataset you used.
@@ -0,0 +1,73 @@
1
+ # ukvocab
2
+
3
+ The published Ukrainian vocabulary data from
4
+ [jakelawrence.xyz/research/ukrainian-frequency](https://www.jakelawrence.xyz/research/ukrainian-frequency),
5
+ installable and offline: the top-12,000 lemma frequency list over UberText 2.0,
6
+ the validated PULS CEFR overlay (A1 to C1), the Borodin A1 and A2 lexical
7
+ minimums, the crosswalk between the minimums and the profile, and the B1
8
+ enrichment tables (aspect relations, case government, collocations).
9
+
10
+ ```python
11
+ import ukvocab
12
+ ukvocab.lookup("п'ять")["level"] # 'A1', with rank, source and enrichment beside it
13
+ ukvocab.coverage(["в", "школа", "йти"], "A2")["coverage"] # share of tokens a validated-A2 learner knows
14
+ ```
15
+
16
+ ## What you get
17
+
18
+ | function | returns |
19
+ |---|---|
20
+ | `lookup(lemma, pos=None)` | one lemma: `level`, `level_source`, `rank`, the frequency figures, every other reading of the spelling, minimum membership, the crosswalk class, and the B1 `enrichment` (aspect partners, government, collocations); `None` when no table knows the string |
21
+ | `levels(lemmas)` | batch: input string to `{level, level_source, rank, pos, source}` |
22
+ | `coverage(lemmas, known_set)` | token coverage of a lemmatized text against a known set: a level (`"A2"` means every validated lemma at or below A2), `"borodin-a1"` / `"borodin-a2"`, an int N (top N of the list), or your own set of lemmas; reports the unknowns by frequency |
23
+ | `sources()` | version, snapshot date, license, attribution, citation and SHA-256 per dataset, the combined license and why, and what is deliberately not shipped |
24
+
25
+ Every return value carries the dataset, version and snapshot it came from.
26
+ Data is Parquet, loaded lazily on first use, never on import. The only
27
+ dependency is `pyarrow`.
28
+
29
+ Two things the package does not do, on purpose. It does not lemmatize: pass
30
+ lemmas in the UberText / VESUM convention (infinitive for verbs, nominative
31
+ singular for nouns); the site's `POST /api/lemmatize` is the hosted lemmatizer
32
+ matched to this list. And it ships no predicted CEFR levels: only the profile's
33
+ validated levels are here, and a lemma the profile does not cover gets
34
+ `level: None` rather than a guess.
35
+
36
+ ## Provenance and honesty
37
+
38
+ The data tier is generated from the site's published download files by
39
+ `scripts/ukrainian-frequency/build-package.py` in the source repository and is
40
+ never edited by hand; the tests assert that every table's row count and hash
41
+ match the published file exactly. `sources()` is the manifest that build
42
+ wrote. The PULS overlay is a dated snapshot (2026-07-23) of a profile that is
43
+ still being extended, redistributed with its authors' permission. The join
44
+ onto the frequency list is part-of-speech aware; ten declared cross-tag rules
45
+ recover the words the two tag conventions merely label differently, and each
46
+ row's `join` field says how it matched, so the strict join is one filter away.
47
+
48
+ ## License
49
+
50
+ The combined package is **CC-BY-NC-SA 4.0**, because the PULS levels are
51
+ NonCommercial and share-alike at the authors' decision (2026-09-24) and they run
52
+ through most tables. The frequency list and the two Borodin minimums are CC-BY
53
+ 4.0 on their own, and the government and collocation tables CC-BY-SA 4.0; drop
54
+ the PULS level columns and NonCommercial does not apply. `LICENSES.md` has the
55
+ per-dataset terms and every attribution.
56
+
57
+ ## Citation
58
+
59
+ ```
60
+ Lawrence, J. (2026). ukvocab: the Ukrainian Frequency datasets (top-12,000 lemma
61
+ list, validated PULS CEFR overlay, Borodin A1/A2 minimums, crosswalk, B1
62
+ enrichment tables), version 0.2.0. jakelawrence.xyz/research/ukrainian-frequency.
63
+
64
+ Upstream: Chaplynskyi, D. (2023). Introducing UberText 2.0: A Corpus of Modern
65
+ Ukrainian at Scale. Proc. 2nd UNLP, ACL. | Synchak, O., Starko, V., Burak, M.,
66
+ Svystun, M. (2025). Corpus-Based Vocabulary Profiling for Ukrainian: From
67
+ Lexical Analysis to CEFR. eLex 2025; PULS, puls.peremova.org. | Borodin, K. and
68
+ Turkevych, O. 1000 i 1 slovo, A1. https://doi.org/10.18452/28236 | Borodin, K.
69
+ and Lazarenko, O. (2026). 1000 i 1 slovo, A2. https://doi.org/10.11584/opus4-1448
70
+ ```
71
+
72
+ Archived, DOI-minted copies of each dataset are listed in `sources()` under
73
+ `conceptDoi` once they exist; cite the concept DOI for the dataset you used.
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ukvocab"
7
+ version = "0.3.0"
8
+ description = "The published Ukrainian vocabulary data from jakelawrence.xyz/research/ukrainian-frequency, offline: the top-12,000 lemma frequency list, the validated PULS CEFR overlay, the Borodin A1 and A2 lexical minimums, their crosswalk, and the B1 enrichment tables."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "CC-BY-NC-SA-4.0"
12
+ license-files = ["LICENSES.md"]
13
+ authors = [{ name = "Jake Lawrence" }]
14
+ keywords = ["ukrainian", "vocabulary", "frequency list", "CEFR", "lexical minimum", "language learning", "corpus"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Education",
18
+ "Intended Audience :: Science/Research",
19
+ "Natural Language :: Ukrainian",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "Topic :: Education",
23
+ "Topic :: Text Processing :: Linguistic",
24
+ ]
25
+ dependencies = ["pyarrow>=14"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://www.jakelawrence.xyz/research/ukrainian-frequency"
29
+ Source = "https://github.com/jake0lawrence/jakelawrence.xyz/tree/main/packages/ukvocab"
30
+ Data = "https://www.jakelawrence.xyz/downloads/ukrainian-frequency/"
31
+
32
+ [project.optional-dependencies]
33
+ test = ["pytest>=7"]
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
37
+
38
+ [tool.setuptools.package-data]
39
+ ukvocab = ["data/*.parquet", "data/manifest.json"]
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,218 @@
1
+ """ukvocab: the published Ukrainian vocabulary data from
2
+ jakelawrence.xyz/research/ukrainian-frequency, offline.
3
+
4
+ Four functions, kept small on purpose:
5
+
6
+ lookup(lemma, pos=None) one lemma: level, its source, rank, enrichment
7
+ levels(lemmas) batch: lemma -> level, with the source
8
+ coverage(lemmas, known_set) the running-text coverage computation
9
+ sources() version, snapshot and license per dataset
10
+
11
+ Every return value carries the dataset, version and snapshot it came from.
12
+ The package does not lemmatize: pass lemmas in the VESUM / UberText
13
+ convention (the infinitive for verbs, the nominative singular for nouns).
14
+ Predicted CEFR levels are deliberately not in this package.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections import Counter
20
+ from typing import Dict, Iterable, List, Optional, Union
21
+
22
+ from . import _store
23
+ from ._store import LEVELS, norm
24
+
25
+ __all__ = ["lookup", "levels", "coverage", "sources", "LEVELS", "norm", "__version__"]
26
+
27
+
28
+ def __getattr__(name):
29
+ if name == "__version__":
30
+ return _store.manifest()["packageVersion"]
31
+ raise AttributeError(name)
32
+
33
+
34
+ LEVEL_SOURCE_VALIDATED = "PULS validated level, POS-aware join onto the frequency list (validated_cefr)"
35
+ LEVEL_SOURCE_CROSSWALK = "PULS validated level via the crosswalk: lemma-string join, no part of speech (crosswalk)"
36
+
37
+
38
+ def _reading(row: dict) -> dict:
39
+ lv = _store.level_by_rank().get(row["rank"])
40
+ return {
41
+ "rank": row["rank"],
42
+ "pos": row["pos"],
43
+ "count": row["count"],
44
+ "permille": row["permille"],
45
+ "cumulative_coverage": row["cumulativeCoverage"],
46
+ "freq_band": row["freqBand"],
47
+ "level": lv["level"] if lv else None,
48
+ "join": lv["join"] if lv else None,
49
+ }
50
+
51
+
52
+ def _enrichment(key: str) -> dict:
53
+ return {
54
+ "aspect": [dict(r) for r in _store.aspect_by_verb().get(key, [])],
55
+ "government": [dict(r) for r in _store.government_by_verb().get(key, [])],
56
+ "collocations": [dict(r) for r in _store.collocations_by_lemma().get(key, [])],
57
+ }
58
+
59
+
60
+ def lookup(lemma: str, pos: Optional[str] = None) -> Optional[dict]:
61
+ """One lemma. Returns None when no table knows the string at all.
62
+
63
+ With `pos` (a UD tag such as NOUN, VERB, ADJ, DET) the reading with that
64
+ part of speech is returned; without it, the most frequent reading, and
65
+ every other reading is listed under `readings` so a homograph is never
66
+ silently collapsed. The level comes from the validated overlay when the
67
+ lemma is on the frequency list (POS-aware); otherwise from the crosswalk,
68
+ which joins by lemma string only, and `level_source` says which.
69
+ """
70
+ key = norm(lemma)
71
+ readings = _store.frequency_by_key().get(key, [])
72
+ if pos is not None:
73
+ readings = [r for r in readings if r["pos"] == pos]
74
+ primary = _reading(readings[0]) if readings else None
75
+ xw = _store.crosswalk_by_key().get(key)
76
+ if primary is None and xw is None:
77
+ return None
78
+
79
+ level = primary["level"] if primary else None
80
+ level_source = LEVEL_SOURCE_VALIDATED if level else None
81
+ if level is None and xw and xw.get("validatedLevel"):
82
+ level = xw["validatedLevel"]
83
+ level_source = LEVEL_SOURCE_CROSSWALK
84
+
85
+ used = ["frequency", "validated_cefr", "crosswalk", "borodin_a1", "borodin_a2"]
86
+ enrichment = _enrichment(key)
87
+ if any(enrichment.values()):
88
+ used += ["b1_aspect", "b1_government", "b1_collocations"]
89
+
90
+ return {
91
+ "query": lemma,
92
+ "lemma": readings[0]["lemma"] if readings else (xw["lemma"] if xw else key),
93
+ "on_list": primary is not None,
94
+ "rank": primary["rank"] if primary else None,
95
+ "pos": primary["pos"] if primary else (xw.get("pos") if xw else None),
96
+ "count": primary["count"] if primary else None,
97
+ "permille": primary["permille"] if primary else None,
98
+ "freq_band": primary["freq_band"] if primary else None,
99
+ "level": level,
100
+ "level_source": level_source,
101
+ "join": primary["join"] if primary else None,
102
+ "readings": [_reading(r) for r in _store.frequency_by_key().get(key, [])],
103
+ "minimums": {
104
+ "a1": key in _store.crosswalk_volume_keys("A1"),
105
+ "a2": key in _store.crosswalk_volume_keys("A2"),
106
+ "a1_entries": [r["entry"] for r in _store.borodin_keys("a1").get(key, [])],
107
+ "a2_entries": [r["entry"] for r in _store.borodin_keys("a2").get(key, [])],
108
+ },
109
+ "crosswalk": {
110
+ "class": xw["class"],
111
+ "volume": xw.get("volume"),
112
+ "validated_level": xw.get("validatedLevel"),
113
+ "relative": xw.get("relative"),
114
+ "relative_via": xw.get("relativeVia"),
115
+ } if xw else None,
116
+ "enrichment": enrichment,
117
+ "sources": {name: _store.source_of(name) for name in used},
118
+ }
119
+
120
+
121
+ def levels(lemmas: Iterable[str]) -> Dict[str, dict]:
122
+ """Batch lookup: input string -> {level, level_source, rank, pos, source}.
123
+ A lemma no table knows gets level None and an empty source."""
124
+ out: Dict[str, dict] = {}
125
+ for lemma in lemmas:
126
+ hit = lookup(lemma)
127
+ if hit is None:
128
+ out[lemma] = {"level": None, "level_source": None, "rank": None, "pos": None, "source": None}
129
+ continue
130
+ src_name = "validated_cefr" if hit["level_source"] == LEVEL_SOURCE_VALIDATED else ("crosswalk" if hit["level"] else None)
131
+ out[lemma] = {
132
+ "level": hit["level"],
133
+ "level_source": hit["level_source"],
134
+ "rank": hit["rank"],
135
+ "pos": hit["pos"],
136
+ "source": _store.source_of(src_name) if src_name else None,
137
+ }
138
+ return out
139
+
140
+
141
+ KnownSet = Union[str, int, Iterable[str]]
142
+
143
+
144
+ def _resolve_known(known_set: KnownSet):
145
+ """A known set is an iterable of lemma strings, a level name (every
146
+ validated lemma at or below it), 'borodin-a1' / 'borodin-a2' (the
147
+ minimum, on and off the list, A2 including A1), or an int N (the top N of
148
+ the frequency list)."""
149
+ if isinstance(known_set, int) and not isinstance(known_set, bool):
150
+ return _store.top_n_set(known_set), f"top {known_set} of the frequency list", ["frequency"]
151
+ if isinstance(known_set, str):
152
+ tag = known_set.strip()
153
+ if tag.upper() in LEVELS:
154
+ return _store.level_set(tag.upper()), f"every validated lemma at or below {tag.upper()}", ["validated_cefr", "frequency"]
155
+ if tag.lower() in ("borodin-a1", "a1-minimum"):
156
+ return _store.crosswalk_volume_keys("A1"), "the Borodin A1 minimum (every headword, on or off the list)", ["crosswalk", "borodin_a1"]
157
+ if tag.lower() in ("borodin-a2", "a2-minimum"):
158
+ return _store.crosswalk_volume_keys("A2"), "the Borodin A1 and A2 minimums (every headword, on or off the list)", ["crosswalk", "borodin_a1", "borodin_a2"]
159
+ raise ValueError(f"unknown known_set name {known_set!r}: a level (A1..C1), 'borodin-a1', 'borodin-a2', an int, or an iterable of lemmas")
160
+ return frozenset(norm(s) for s in known_set), "a caller-supplied set of lemmas", ["frequency"]
161
+
162
+
163
+ def coverage(lemmas: Iterable[str], known_set: KnownSet, top_unknown: int = 20) -> dict:
164
+ """Running-text coverage of a lemmatized text against a known set.
165
+
166
+ `lemmas` is the text as a sequence of lemmas, one per token, repeats
167
+ included (coverage is token coverage, not type coverage). Tokens are
168
+ counted as known when their normalized lemma is in the known set; the
169
+ package does not decide what a word is, so pass only the tokens you want
170
+ counted (a caller usually drops punctuation, numbers and proper names).
171
+ Also reports how many tokens are on the frequency list at all, and the
172
+ most frequent unknown lemmas, so a gap is a list rather than a number.
173
+ """
174
+ known, description, used = _resolve_known(known_set)
175
+ counts = Counter(norm(t) for t in lemmas)
176
+ total = sum(counts.values())
177
+ known_tokens = sum(n for k, n in counts.items() if k in known)
178
+ on_list = sum(n for k, n in counts.items() if k in _store.frequency_by_key())
179
+ unknown = Counter({k: n for k, n in counts.items() if k not in known})
180
+ by_level: Dict[str, int] = {lvl: 0 for lvl in LEVELS}
181
+ unleveled = 0
182
+ for k, n in counts.items():
183
+ readings = _store.frequency_by_key().get(k)
184
+ lv = _store.level_by_rank().get(readings[0]["rank"]) if readings else None
185
+ if lv:
186
+ by_level[lv["level"]] += n
187
+ else:
188
+ unleveled += n
189
+ return {
190
+ "tokens": total,
191
+ "types": len(counts),
192
+ "known": known_tokens,
193
+ "unknown": total - known_tokens,
194
+ "coverage": (known_tokens / total) if total else 0.0,
195
+ "on_list": on_list,
196
+ "on_list_share": (on_list / total) if total else 0.0,
197
+ "by_validated_level": by_level,
198
+ "unleveled": unleveled,
199
+ "known_set": description,
200
+ "known_set_size": len(known),
201
+ "top_unknown": unknown.most_common(top_unknown),
202
+ "sources": {name: _store.source_of(name) for name in used},
203
+ }
204
+
205
+
206
+ def sources() -> dict:
207
+ """Version, snapshot, license, attribution, citation and checksum per
208
+ dataset, plus the combined license and why, straight from the manifest."""
209
+ m = _store.manifest()
210
+ return {
211
+ "package": m["package"],
212
+ "package_version": m["packageVersion"],
213
+ "site": m["site"],
214
+ "combined_license": m["combinedLicense"],
215
+ "combined_license_why": m["combinedLicenseWhy"],
216
+ "not_shipped": list(m["notShipped"]),
217
+ "datasets": {name: dict(d) for name, d in m["datasets"].items()},
218
+ }
@@ -0,0 +1,172 @@
1
+ """Lazy, read-only access to the package's Parquet data tier.
2
+
3
+ Nothing is read at import time. Each table loads on first use through
4
+ pyarrow, and the lookup indexes are built once per process. The manifest
5
+ (manifest.json, generated by scripts/ukrainian-frequency/build-package.py in
6
+ the source repository) is the only description of the data; this module
7
+ never hard-codes a row count, a license or a snapshot date.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import unicodedata
15
+ from functools import lru_cache
16
+ from typing import Dict, List, Optional
17
+
18
+ DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
19
+
20
+ LEVELS = ("A1", "A2", "B1", "B2", "C1")
21
+ LEVEL_INDEX = {lvl: i for i, lvl in enumerate(LEVELS)}
22
+
23
+ _APOSTROPHES = ("'", "ʼ", "’", "‘", "ʹ", "`", "´")
24
+
25
+
26
+ def norm(s: str) -> str:
27
+ """The join key used across every table: NFC, the pedagogical stress mark
28
+ (U+0301) stripped, every apostrophe glyph folded to U+2019 (the frequency
29
+ list's glyph), whitespace trimmed, lowercased."""
30
+ s = unicodedata.normalize("NFD", s or "")
31
+ s = "".join(ch for ch in s if ch != "́")
32
+ s = unicodedata.normalize("NFC", s)
33
+ for glyph in _APOSTROPHES:
34
+ s = s.replace(glyph, "’")
35
+ return s.strip().lower()
36
+
37
+
38
+ @lru_cache(maxsize=None)
39
+ def manifest() -> dict:
40
+ with open(os.path.join(DATA_DIR, "manifest.json"), encoding="utf-8") as fh:
41
+ return json.load(fh)
42
+
43
+
44
+ @lru_cache(maxsize=None)
45
+ def table(name: str):
46
+ """The pyarrow Table for a dataset name from the manifest."""
47
+ import pyarrow.parquet as pq
48
+
49
+ meta = manifest()["datasets"][name]
50
+ return pq.read_table(os.path.join(DATA_DIR, meta["file"]))
51
+
52
+
53
+ @lru_cache(maxsize=None)
54
+ def rows(name: str) -> List[dict]:
55
+ """The dataset as a list of plain dicts (nulls as None)."""
56
+ return table(name).to_pylist()
57
+
58
+
59
+ def source_of(name: str) -> dict:
60
+ """The provenance stamp every return value carries for a dataset."""
61
+ m = manifest()["datasets"][name]
62
+ return {
63
+ "dataset": name,
64
+ "version": m["version"],
65
+ "snapshot": m["snapshot"],
66
+ "license": m["license"],
67
+ "rows": m["rows"],
68
+ "sha256": m["sha256"],
69
+ "conceptDoi": m.get("conceptDoi"),
70
+ }
71
+
72
+
73
+ # ---------------------------------------------------------------- indexes
74
+ @lru_cache(maxsize=None)
75
+ def frequency_by_key() -> Dict[str, List[dict]]:
76
+ """norm(lemma) -> frequency rows (rank order); a spelling can have several
77
+ readings because the corpus tagger split it by part of speech."""
78
+ out: Dict[str, List[dict]] = {}
79
+ for r in rows("frequency"):
80
+ out.setdefault(norm(r["lemma"]), []).append(r)
81
+ for v in out.values():
82
+ v.sort(key=lambda r: r["rank"])
83
+ return out
84
+
85
+
86
+ @lru_cache(maxsize=None)
87
+ def frequency_by_rank() -> Dict[int, dict]:
88
+ return {r["rank"]: r for r in rows("frequency")}
89
+
90
+
91
+ @lru_cache(maxsize=None)
92
+ def level_by_rank() -> Dict[int, dict]:
93
+ """rank -> validated overlay row (level, join)."""
94
+ return {r["rank"]: r for r in rows("validated_cefr")}
95
+
96
+
97
+ @lru_cache(maxsize=None)
98
+ def crosswalk_by_key() -> Dict[str, dict]:
99
+ return {norm(r["lemma"]): r for r in rows("crosswalk")}
100
+
101
+
102
+ @lru_cache(maxsize=None)
103
+ def borodin_keys(volume: str) -> Dict[str, List[dict]]:
104
+ """norm(headword lemma) -> the matched entries of a Borodin volume. Only
105
+ matched entries carry a lemma; off-list headwords are reached through the
106
+ crosswalk's volume column instead."""
107
+ name = {"a1": "borodin_a1", "a2": "borodin_a2"}[volume]
108
+ out: Dict[str, List[dict]] = {}
109
+ for r in rows(name):
110
+ if r.get("matched") and r.get("lemma"):
111
+ out.setdefault(norm(r["lemma"]), []).append(r)
112
+ return out
113
+
114
+
115
+ @lru_cache(maxsize=None)
116
+ def crosswalk_volume_keys(volume: str) -> frozenset:
117
+ """Every lemma string a Borodin volume carries, on or off the frequency
118
+ list, as the crosswalk records it (a word in both volumes counts as A1)."""
119
+ keys = set()
120
+ for r in rows("crosswalk"):
121
+ v = r.get("volume")
122
+ if v == volume or (volume == "A2" and v == "A1"):
123
+ keys.add(norm(r["lemma"]))
124
+ return frozenset(keys)
125
+
126
+
127
+ @lru_cache(maxsize=None)
128
+ def aspect_by_verb() -> Dict[str, List[dict]]:
129
+ out: Dict[str, List[dict]] = {}
130
+ for r in rows("b1_aspect"):
131
+ out.setdefault(norm(r["b1_verb"]), []).append(r)
132
+ return out
133
+
134
+
135
+ @lru_cache(maxsize=None)
136
+ def government_by_verb() -> Dict[str, List[dict]]:
137
+ out: Dict[str, List[dict]] = {}
138
+ for r in rows("b1_government"):
139
+ out.setdefault(norm(r["verb"]), []).append(r)
140
+ return out
141
+
142
+
143
+ @lru_cache(maxsize=None)
144
+ def collocations_by_lemma() -> Dict[str, List[dict]]:
145
+ out: Dict[str, List[dict]] = {}
146
+ for r in rows("b1_collocations"):
147
+ out.setdefault(norm(r["lemma"]), []).append(r)
148
+ return out
149
+
150
+
151
+ @lru_cache(maxsize=None)
152
+ def level_set(level: str) -> frozenset:
153
+ """norm(lemma) of every validated lemma at or below a level."""
154
+ if level not in LEVEL_INDEX:
155
+ raise ValueError(f"unknown level {level!r}; one of {LEVELS}")
156
+ top = LEVEL_INDEX[level]
157
+ by_rank = frequency_by_rank()
158
+ return frozenset(norm(by_rank[r["rank"]]["lemma"]) for r in rows("validated_cefr") if LEVEL_INDEX[r["level"]] <= top)
159
+
160
+
161
+ @lru_cache(maxsize=None)
162
+ def top_n_set(n: int) -> frozenset:
163
+ return frozenset(norm(r["lemma"]) for r in rows("frequency") if r["rank"] <= n)
164
+
165
+
166
+ def clear_cache() -> None:
167
+ """Drop every loaded table and index (tests, or long processes that want
168
+ the memory back)."""
169
+ for fn in (manifest, table, rows, frequency_by_key, frequency_by_rank, level_by_rank, crosswalk_by_key,
170
+ borodin_keys, crosswalk_volume_keys, aspect_by_verb, government_by_verb, collocations_by_lemma,
171
+ level_set, top_n_set):
172
+ fn.cache_clear()