File size: 10,453 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
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
"""Label-projection correctness: alignment round-trips on real corpus lines, and the
ambiguity mask checked against the old project's `markable()` (ported verbatim)."""
import json
import os
import unicodedata
from pathlib import Path

import numpy as np
import pytest

from meter.dataset import (concat_verses, encode_macron_line, encode_plain,
                           encode_scan_line)
from meter.marks import (MAC_LONG, MAC_SHORT, SCAN_HEAVY, SCAN_LIGHT, SCAN_VERSE,
                         ambiguous_mask, bracketize, enforce_circumflex_heavy,
                         insert_marks, merge_vowelless_syllables,
                         parse_macron_line, parse_scan_line)

SRC = Path(os.path.expandvars(os.environ.get("MACRONIZER_SRC",
                          "$MACRONIZER_SRC")))

# The four tests below read the released meter-silver tree (MACRONIZER_SRC). Skip them
# rather than fail when it is not checked out: the rest of this file is pure unit tests.
needs_src = pytest.mark.skipif(
    not (SRC / "data").is_dir(),
    reason="MACRONIZER_SRC not set to a checkout of the meter-silver dataset")

# ---------------------------------------------------------------- old-project reference
DICHRONA = set("αιυ")
DIPHTHONGS = {"αι", "αυ", "ει", "ευ", "ηυ", "οι", "ου", "υι", "ωυ"}
PERISPOMENI, YPOGEGRAMMENI, DIAERESIS = "͂", "ͅ", "̈"


def _base(ch):
    return unicodedata.normalize("NFD", ch)[0].lower()


def _has(ch, mark):
    return mark in unicodedata.normalize("NFD", ch)


def markable_ref(chars, i):
    """Verbatim port of the macron data tree/scripts/macronize_corpus.py::markable."""
    ch = chars[i]
    b = _base(ch)
    if b not in DICHRONA:
        return False
    if _has(ch, PERISPOMENI) or _has(ch, YPOGEGRAMMENI):
        return False
    if i > 0 and not _has(ch, DIAERESIS) and _base(chars[i - 1]) + b in DIPHTHONGS:
        return False
    if (i + 1 < len(chars) and not _has(chars[i + 1], DIAERESIS)
            and b + _base(chars[i + 1]) in DIPHTHONGS):
        return False
    return True


# ---------------------------------------------------------------- macron parsing

def test_parse_macron_simple():
    plain, labels = parse_macron_line("ὦ παῖ τέλος μὲν Ζεὺς ἔχει βα^ρύκτυ^πος")
    assert plain == "ὦ παῖ τέλος μὲν Ζεὺς ἔχει βαρύκτυπος"
    # letters: ω π α ι τ ε λ ο σ μ ε ν ζ ε υ σ ε χ ε ι β α(21) ρ υ κ τ υ(26) π ο σ
    assert labels == {21: MAC_SHORT, 26: MAC_SHORT}


def test_parse_macron_combining_marks():
    plain, labels = parse_macron_line("βᾱρῠ́ς")   # combining macron + breve-with-acute
    assert plain == "βαρύς"
    assert labels == {1: MAC_LONG, 3: MAC_SHORT}   # β0 α1 ρ2 υ3 ς4


@needs_src
def test_macron_roundtrip_against_plain_column():
    """TSV col1 (plain) and col2 (marked) must strip to identical letter streams."""
    checked = 0
    for name in ("hypotactic", "oga_0", "anthology", "theocritus_doric"):
        path = SRC / "data" / f"{name}.tsv"
        with open(path, encoding="utf-8") as f:
            for _ in range(300):
                line = f.readline()
                if not line:
                    break
                plain_col, marked = line.rstrip("\n").split("\t")[:2]
                plain, labels = parse_macron_line(marked)
                r1, r2 = encode_plain(plain), encode_plain(plain_col)
                if r1 is None or r2 is None:
                    continue
                assert np.array_equal(r1.chars, r2.chars), (name, marked)
                if labels:
                    assert max(labels) < len(r1.chars)
                checked += 1
    assert checked > 850   # theocritus_doric has only 18 lines


def test_insert_marks_roundtrip():
    for marked in ("ἥσθην δὲ βαιά^, πά^νυ^ δὲ βαιά^, τέττα^ρα^·",
                   "Δάφνι τά_λαν, τί_ τὺ_ τά_κεαι, ἁ_ δέ τε κώρα",
                   "χρὴ γι^νώσκειν ὅτι^ πά_σης τῆς γῆς ὁ περί^μετρος 0 ."):
        plain, labels = parse_macron_line(marked)
        again = insert_marks(plain, labels)
        assert parse_macron_line(again) == (plain, labels)
        assert unicodedata.normalize("NFC", again) == unicodedata.normalize("NFC", marked)


# ---------------------------------------------------------------- ambiguity mask

def _mask_via_planes(text):
    rec = encode_plain(text)
    return rec, ambiguous_mask(rec.chars, rec.boundary, rec.dia)


@needs_src
def test_ambiguous_mask_matches_reference():
    lines = []
    for name in ("hypotactic", "oga_1", "anthology", "drama_ia6"):
        with open(SRC / "data" / f"{name}.tsv", encoding="utf-8") as f:
            for _ in range(200):
                line = f.readline()
                if not line:
                    break
                lines.append(line.split("\t")[0])
    checked = 0
    for text in lines:
        text = unicodedata.normalize("NFC", text)
        rec = encode_plain(text)
        if rec is None:
            continue
        # reference mask over raw chars, projected to letter ordinals
        chars = list(text)
        ref = []
        for i, ch in enumerate(chars):
            if _base(ch).lower() in set("αβγδεζηθικλμνξοπρστυφχψω") | {"ς", "ϲ"}:
                ref.append(markable_ref(chars, i))
        if len(ref) != len(rec.chars):
            continue  # letters the raw walk counts differently (archaic etc.) — rare
        ours = ambiguous_mask(rec.chars, rec.boundary, rec.dia)
        assert ref == ours.tolist(), text
        checked += 1
    assert checked > 500


# ---------------------------------------------------------------- scanner parsing

def test_parse_scan_simple():
    line = "[ὦ] [παῖ] {τέ}[λος] [μὲν] [Ζεὺ]{ς ἔ}[χει] {βα}[ρύκ]{τυ}[πος]"
    plain, labels = parse_scan_line(line)
    assert plain == "ὦ παῖ τέλος μὲν Ζεὺς ἔχει βαρύκτυπος"
    # letter ordinals:  ὦ=0 π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
    assert labels[0] == SCAN_HEAVY and labels[3] == SCAN_HEAVY
    assert labels[5] == SCAN_LIGHT and labels[8] == SCAN_HEAVY
    assert labels[14] == SCAN_HEAVY and labels[16] == SCAN_LIGHT  # [Ζεὺ] ends at ὺ
    assert labels[29] == SCAN_VERSE
    assert max(labels) == 29


@needs_src
def test_scan_corpus_lines_encode():
    ok = 0
    with open(SRC / "data/scanner/corpus_v3.tsv", encoding="utf-8") as f:
        for _ in range(500):
            line = f.readline()
            if not line:
                break
            work, _meter, bracketed = line.rstrip("\n").split("\t")
            rec = encode_scan_line(bracketed)
            if rec is None:
                continue
            ends = (rec.y_scan > 0).sum()
            assert (rec.y_scan == SCAN_VERSE).sum() == 1
            assert ends >= 2, bracketed
            ok += 1
    assert ok > 450


def test_concat_verses_boundaries():
    r1 = encode_scan_line("[ὦ] [παῖ] {τέ}[λος]")
    r2 = encode_scan_line("{βα}[ρύκ]{τυ}[πος]")
    joined = concat_verses([r1, r2])
    n1 = len(r1)
    assert joined.boundary[n1 - 1] == 1        # seam demoted to word boundary
    assert joined.boundary[-1] == 2            # record end keeps sentence boundary
    assert (joined.y_scan == SCAN_VERSE).sum() == 2


def test_bracketize_roundtrip():
    line = "[ὦ] [παῖ] {τέ}[λος] [μὲν] [Ζεὺ]{ς ἔ}[χει] {βα}[ρύκ]{τυ}[πος]"
    plain, labels = parse_scan_line(line)
    out = bracketize(plain, {k: v for k, v in labels.items()})
    plain2, labels2 = parse_scan_line(out)
    assert plain2 == plain
    assert labels2 == labels


def test_enforce_circumflex_heavy_overrides_light():
    # "πᾶς" (circumflex on alpha, closed by sigma): a syllable containing a
    # circumflex is always heavy in Greek prosody, regardless of what the
    # per-letter classifier predicted.
    rec = encode_plain("πᾶς")
    labels = np.zeros(len(rec.chars), dtype=np.int64)
    labels[-1] = SCAN_LIGHT   # simulates the model's wrong prediction
    fixed = enforce_circumflex_heavy(rec.dia, labels)
    assert fixed[-1] == SCAN_HEAVY


def test_enforce_circumflex_heavy_leaves_non_circumflex_alone():
    rec = encode_plain("πολις")
    labels = np.zeros(len(rec.chars), dtype=np.int64)
    labels[-1] = SCAN_LIGHT
    fixed = enforce_circumflex_heavy(rec.dia, labels)
    assert fixed[-1] == SCAN_LIGHT


def test_merge_vowelless_syllables():
    # "{λε}[ν]" -> "[λεν]": a vowel-less span ("ν" alone) can't be a real
    # syllable -- fold it into the preceding one, keeping its own weight.
    plain, labels = parse_scan_line("{λε}[ν]")
    rec = encode_plain(plain)
    arr = np.zeros(len(rec.chars), dtype=np.int64)
    for k, v in labels.items():
        arr[k] = v
    fixed = merge_vowelless_syllables(rec.chars, arr)
    out = bracketize(plain, {i: int(l) for i, l in enumerate(fixed) if l})
    assert out == "[λεν]"


def test_merge_vowelless_syllables_leaves_real_syllables_alone():
    plain, labels = parse_scan_line("[ὦ] [παῖ]")
    rec = encode_plain(plain)
    arr = np.zeros(len(rec.chars), dtype=np.int64)
    for k, v in labels.items():
        arr[k] = v
    fixed = merge_vowelless_syllables(rec.chars, arr)
    assert fixed.tolist() == arr.tolist()


# ---------------------------------------------------------------- norma gold

@needs_src
def test_norma_lines_parse():
    n_mac = n_syl = 0
    with open(SRC / "data/norma/test.jsonl", encoding="utf-8") as f:
        for line in f:
            d = json.loads(line)
            if d["task"] == "macronize":
                plain, labels = parse_macron_line(d["text"])
                rec = encode_plain(plain)
                if labels:
                    assert rec is not None and max(labels) < len(rec.chars), d
                n_mac += 1
            else:
                parsed = parse_scan_line(d["text"])
                assert parsed is not None, d
                plain, labels = parsed
                rec = encode_plain(plain)
                assert rec is not None and max(labels) < len(rec.chars), d
                n_syl += 1
    assert n_mac == 932 and n_syl == 932