File size: 8,365 Bytes
7ed86c3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 | """Property tests for data/normalize.py.
The load-bearing invariant: normalize() must be exactly invertible to
(a) the accentless spaced/sentence-marked text and (b) the polytonic word list,
where the reference for both is computed by an INDEPENDENT, slow, obviously-correct
pure-Python implementation. Any misalignment between chars and label planes breaks these.
"""
import sys, unicodedata
from pathlib import Path
import numpy as np
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from data.normalize import (ALPHABET, LETTER_IDS, _EXTRA_BASE, ARCHAIC, DIA_STATES,
Stats, denormalize, normalize_record, restore_polytonic,
unpack_dia, _pack_dia)
import os
RAW = Path(os.path.expandvars(os.environ.get("STOICHEIA_DATA", "$STOICHEIA_DATA"))) \
/ "raw/AncientGreek/data"
# ------------------------------------------------------------ reference impl
def _ref_letter(ch):
low = ch.lower()
if low in LETTER_IDS:
return low
if low in _EXTRA_BASE:
return _EXTRA_BASE[low]
return None
def ref_words_accentless(text):
"""Independent reference: (accentless word, sentence_final) pairs."""
nfd = unicodedata.normalize("NFD", text)
words, cur, seen_spunct = [], [], False
sfinal = {".", ";", "!", "?"}
for ch in nfd:
if ch in ARCHAIC or unicodedata.category(ch) in ("Mn", "Mc", "Me"):
if ch in ARCHAIC and cur:
pass # archaic letters act as separators (weight 1) in the kernel
if unicodedata.category(ch) in ("Mn", "Mc", "Me"):
continue
letter = _ref_letter(ch)
if letter is not None:
cur.append(letter)
seen_spunct = False
else:
if cur:
words.append(["".join(cur), ch in sfinal])
cur = []
elif words and ch in sfinal:
words[-1][1] = True
continue
if cur:
words.append(["".join(cur), True])
if words:
words[-1][1] = True
return [(w, s) for w, s in words]
def ref_polytonic_words(text):
"""Reference polytonic words: NFC-fold each maximal run of Greek letters+marks."""
nfd = unicodedata.normalize("NFD", text)
keep_marks = {0x0301, 0x0341, 0x0300, 0x0340, 0x0342, 0x0302, 0x0313, 0x0343,
0x0314, 0x0345, 0x0308}
words, cur = [], []
for ch in nfd:
if _ref_letter(ch) is not None:
cur.append(ch)
elif unicodedata.category(ch) in ("Mn", "Mc", "Me"):
if cur and ord(ch) in keep_marks:
cur.append(ch)
else:
if cur:
words.append(unicodedata.normalize("NFC", "".join(cur)))
cur = []
if cur:
words.append(unicodedata.normalize("NFC", "".join(cur)))
return words
def _fold(w):
"""Fold a reference word the way the pipeline folds letters (case, variants, ς)."""
out = []
nfd = unicodedata.normalize("NFD", w)
for ch in nfd:
if unicodedata.category(ch) in ("Mn", "Mc", "Me"):
out.append(ch)
else:
l = _ref_letter(ch)
cap = ch != ch.lower() or ch == "Ϲ"
out.append(l.upper() if cap else l)
s = "".join(out)
return unicodedata.normalize("NFC", s)
def _final_sigma(w):
# base string uses σ internally; word-final becomes ς — mirror restore_polytonic
nfc = unicodedata.normalize("NFC", w)
if nfc.endswith("σ"):
return nfc[:-1] + "ς"
return nfc
# ------------------------------------------------------------ unit cases
def norm(text):
st = Stats()
r = normalize_record(text, st)
assert r is not None
return (*r, st)
def test_kai_ho_logos():
chars, boundary, dia, cap, st = norm("καὶ ὁ λόγος.")
assert "".join(ALPHABET[c] for c in chars) == "καιολογοσ"
assert boundary.tolist() == [0, 0, 1, 1, 0, 0, 0, 0, 2]
accs = [unpack_dia(int(d))[0] for d in dia]
brs = [unpack_dia(int(d))[1] for d in dia]
assert accs == [0, 0, 2, 0, 0, 1, 0, 0, 0] # grave on ι, acute on ο of λόγος
assert brs == [0, 0, 0, 2, 0, 0, 0, 0, 0] # rough on ὁ
assert cap.tolist() == [0] * 9
assert st.words == 3 and st.sentences == 1
def test_punct_without_space_is_sentence_final():
chars, boundary, dia, cap, _ = norm("σπείρεις.Πλίνθον πλύνεις")
text = denormalize(chars, boundary)
assert text.startswith("σπειρεισ. πλινθον πλυνεισ")
assert cap[8] == 1 # Π
def test_elision_apostrophe_is_word_boundary():
for apo in ["᾽", "'", "’", "᾽"]:
chars, boundary, dia, cap, _ = norm(f"ἀλλ{apo} οὐ γάρ")
assert denormalize(chars, boundary) == "αλλ ου γαρ."
def test_capital_with_breathing():
chars, boundary, dia, cap, _ = norm("Ὅλον")
assert cap[0] == 1
acc, br, io, dd = unpack_dia(int(dia[0]))
assert br == 2 and acc == 1 # rough breathing + acute
def test_iota_subscript_and_diaeresis():
chars, boundary, dia, cap, _ = norm("τῷ Ῥόδῳ ἀΐδιος")
a0 = unpack_dia(int(dia[1])) # ῷ: circumflex + iota-sub
assert a0[0] == 3 and a0[2] == 1
r = unpack_dia(int(dia[2])) # Ῥ rough
assert r[1] == 2 and cap[2] == 1
ii = unpack_dia(int(dia[7])) # ΐ of ἀΐδιος: acute + diaeresis
assert ii[0] == 1 and ii[3] == 1
def test_final_and_lunate_sigma_merge():
chars, _, _, cap, _ = norm("λόγος λόγοϲ ΛΟΓΟΣ")
s = "".join(ALPHABET[c] for c in chars)
assert s == "λογοσ" * 3
assert cap[10:15].tolist() == [1] * 5
def test_dia_pack_unpack_roundtrip():
for acc in range(4):
for br in range(3):
for io in range(2):
for dd in range(2):
d = _pack_dia(acc, br, io, dd)
assert 0 <= d < DIA_STATES
assert unpack_dia(int(d)) == (acc, br, io, dd)
def test_drops_nongreek_record():
st = Stats()
assert normalize_record("This is an English sentence entirely.", st) is None
assert st.records_dropped_nongreek + st.records_dropped_empty == 1
def test_restore_polytonic_simple():
chars, boundary, dia, cap, _ = norm("καὶ ὁ λόγος ἦν πρὸς τὸν θεόν.")
words = restore_polytonic(chars, dia, cap, boundary)
assert words == ["καὶ", "ὁ", "λόγος", "ἦν", "πρὸς", "τὸν", "θεόν"]
# ------------------------------------------------------------ corpus property test
@pytest.mark.parametrize("shard", ["pristine/pristine-00000.parquet",
"repaired/repaired-00000.parquet"])
def test_roundtrip_on_real_records(shard):
pq = pytest.importorskip("pyarrow.parquet")
if not (RAW / shard).is_file():
pytest.skip("pretraining corpus shards not present under $STOICHEIA_DATA")
t = pq.read_table(RAW / shard, columns=["text"])
rng = np.random.default_rng(0)
idx = rng.choice(t.num_rows, size=200, replace=False)
texts = [t.column("text")[int(i)].as_py() for i in idx]
st = Stats()
checked = 0
for text in texts:
r = normalize_record(text, st)
if r is None:
continue
chars, boundary, dia, cap = r
assert len(chars) == len(boundary) == len(dia) == len(cap)
# (a) accentless words + sentence flags match the reference exactly
ref = ref_words_accentless(text)
got_words, got_flags, cur = [], [], []
for c, b in zip(chars, boundary):
cur.append(ALPHABET[c])
if b >= 1:
got_words.append("".join(cur)); got_flags.append(b == 2); cur = []
assert got_words == [w for w, _ in ref], f"word mismatch in record"
assert got_flags == [s for _, s in ref], f"sentence-flag mismatch"
# (b) polytonic restoration matches the reference NFC words
ref_poly = [_final_sigma(_fold(w)) for w in ref_polytonic_words(text)]
got_poly = restore_polytonic(chars, dia, cap, boundary)
assert got_poly == ref_poly, "polytonic mismatch"
checked += 1
assert checked >= 150 # most sampled records survive filtering
|