File size: 9,272 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
"""Project macron / scansion annotations onto Stoicheia's letter planes.

The backbone codec (data/normalize.py) turns text into per-LETTER planes: every Greek
letter is one position; spaces, punctuation and editorial marks are folded into the
boundary/punct channels or stripped. Both annotation formats mark *letters*:

  macronized text   `_` (long) / `^` (short) written after an ambiguous dichronon,
                    e.g. "βα^ρύκτυ^πος"; combining macron/breve are accepted too
  bracketed verse   [heavy] {light} syllable spans, weight belonging to the last
                    letter of the span, the line-final syllable being verse-end
                    (brevis in longo), e.g. "[ὦ] [παῖ] {τέ}[λος] ..."

So a label is "letter ordinal -> class". We walk the annotated text with the SAME
character-kind LUT the codec uses, counting letters exactly as normalize_record will
count them on the stripped text — projection is alignment-exact by construction and
asserted at encode time.

Label conventions follow an existing macronization project (its Norma scorer and
scanner corpus are reused verbatim):
  macron: 0 = long, 1 = short
  scan:   0 = none, 1 = heavy syllable ends here, 2 = light ends, 3 = verse ends
"""
from __future__ import annotations

import re
import unicodedata

import numpy as np

from meter.backbone import ALPHABET  # noqa: F401  (ensures STOICHEIA_ROOT is on sys.path)
from data.normalize import _KIND, K_LETTER, LETTER_IDS, unpack_dia

MAC_LONG, MAC_SHORT = 0, 1
SCAN_O, SCAN_HEAVY, SCAN_LIGHT, SCAN_VERSE = 0, 1, 2, 3
IGNORE = -100

# annotation characters (never part of the codec's letter set)
_LONG_MARKS = {"_", "̄"}   # ASCII underscore, combining macron
_SHORT_MARKS = {"^", "̆"}  # ASCII caret, combining breve
_ALL_MARKS = _LONG_MARKS | _SHORT_MARKS

_A, _E, _H, _I, _O, _Y, _W = (LETTER_IDS[c] for c in "αεηιουω")
DICHRONA_IDS = np.array([_A, _I, _Y])
VOWEL_IDS = np.array([_A, _E, _H, _I, _O, _Y, _W])
# (first, second) letter-id pairs that form a diphthong
DIPHTHONGS = {(_A, _I), (_A, _Y), (_E, _I), (_E, _Y), (_H, _Y),
              (_O, _I), (_O, _Y), (_Y, _I), (_W, _Y)}


def _is_letter(ch: str) -> bool:
    """Does this (possibly precomposed) character contribute one codec letter?"""
    cp = ord(unicodedata.normalize("NFD", ch)[0])
    return cp < len(_KIND) and _KIND[cp] == K_LETTER


def parse_macron_line(marked: str):
    """Annotated line -> (plain_text, {letter_ordinal: MAC_LONG|MAC_SHORT}).

    plain_text is the line with all length marks removed (NFC); letter ordinals
    count codec letters and therefore index normalize_record's planes directly.
    """
    nfd = unicodedata.normalize("NFD", marked)
    kept, labels = [], {}
    ordinal = -1
    for ch in nfd:
        if ch in _ALL_MARKS:
            if ordinal >= 0:
                labels[ordinal] = MAC_LONG if ch in _LONG_MARKS else MAC_SHORT
            continue
        cp = ord(ch)
        if cp < len(_KIND) and _KIND[cp] == K_LETTER:
            ordinal += 1
        kept.append(ch)
    return unicodedata.normalize("NFC", "".join(kept)), labels


_SYL = re.compile(r"\[([^\]]*)\]|\{([^}]*)\}")


def parse_scan_line(bracketed: str):
    """Bracketed verse -> (plain_text, {letter_ordinal: scan class}).

    The syllable weight sits on the LAST letter of the span (the old corpus puts it
    on the last non-space character, which can be an apostrophe — we take the last
    codec letter instead, which is what the planes can address). The final labeled
    letter of the line becomes SCAN_VERSE. Returns None for lines with no syllables.
    """
    plain_parts, labels = [], {}
    ordinal = -1
    pos = 0
    last_labeled = None

    def advance(text):
        nonlocal ordinal
        last = None
        for ch in text:
            if _is_letter(ch):
                ordinal += 1
                last = ordinal
        plain_parts.append(text)
        return last

    for m in _SYL.finditer(bracketed):
        advance(bracketed[pos:m.start()])
        text, weight = ((m.group(1), SCAN_HEAVY) if m.group(1) is not None
                        else (m.group(2), SCAN_LIGHT))
        last = advance(text)
        if last is not None:
            labels[last] = weight
            last_labeled = last
        pos = m.end()
    advance(bracketed[pos:])
    if last_labeled is None:
        return None
    labels[last_labeled] = SCAN_VERSE
    return unicodedata.normalize("NFC", "".join(plain_parts)), labels


def ambiguous_mask(chars: np.ndarray, boundary: np.ndarray, dia: np.ndarray):
    """Which plane positions are ambiguous dichrona (the macronizer's domain)?

    A position is ambiguous iff it is a base α/ι/υ that does not carry circumflex or
    iota subscript and is not part of a diphthong; diaeresis on the second vowel
    breaks the diphthong, and pairs never span a word boundary. Same rule as the old
    project's `markable()` (macronize_corpus.py), computed on the planes.
    """
    n = len(chars)
    d = np.asarray(dia, dtype=np.int64)
    acc, _br, iota, diaer = unpack_dia(d.copy())
    is_dich = np.isin(chars, DICHRONA_IDS)
    out = is_dich & (acc != 3) & (iota == 0)
    if n > 1:
        pair = np.zeros(n - 1, dtype=bool)
        for f, s in DIPHTHONGS:
            pair |= (chars[:-1] == f) & (chars[1:] == s)
        pair &= boundary[:-1] == 0          # no word boundary inside a diphthong
        # second element of a diphthong (unless it carries diaeresis)
        out[1:] &= ~(pair & (diaer[1:] == 0))
        # first element of a diphthong (unless the second carries diaeresis)
        out[:-1] &= ~(pair & (diaer[1:] == 0))
    return out


def merge_vowelless_syllables(chars: np.ndarray, scan_labels: np.ndarray) -> np.ndarray:
    """A predicted "syllable" span with no vowel isn't a syllable -- it's a boundary
    placed one letter early, typically at the first of a geminate consonant pair
    (e.g. predicted "{λε}[ν]" for what should be one closed syllable "[λεν]").
    Merge any such span into the PRECEDING one by dropping the earlier boundary,
    keeping the vowel-less span's OWN weight label: that label (usually SCAN_HEAVY,
    since it's a closing consonant) is normally already correct for the merged
    syllable -- only the boundary was misplaced. A vowel-less span at the very
    start of the line (no preceding syllable to merge into) is left as-is."""
    out = np.asarray(scan_labels).copy()
    is_vowel = np.isin(chars, VOWEL_IDS)
    kept = []
    start = 0
    for i in range(len(out)):
        if out[i] == SCAN_O:
            continue
        if not is_vowel[start:i + 1].any() and kept:
            out[kept.pop()] = SCAN_O
        kept.append(i)
        start = i + 1
    return out


def enforce_circumflex_heavy(dia: np.ndarray, scan_labels: np.ndarray) -> np.ndarray:
    """Circumflex marks a categorically long vowel, and a syllable containing one is
    always heavy -- a fixed rule of Greek prosody, not something the per-letter scan
    classifier can get wrong in principle, only in practice. Walk each predicted
    syllable span (consecutive letters up to and including the next non-SCAN_O
    label); if it contains a circumflexed letter and the model called it
    SCAN_LIGHT, flip that span's label to SCAN_HEAVY. SCAN_VERSE is left alone (it
    already renders as a heavy-looking bracket); boundary PLACEMENT is untouched --
    this only corrects a syllable's weight, never whether one was predicted there."""
    d = np.asarray(dia, dtype=np.int64)
    acc, _br, _iota, _diaer = unpack_dia(d.copy())
    has_circ = acc == 3
    out = np.asarray(scan_labels).copy()
    start = 0
    for i in range(len(out)):
        if out[i] != SCAN_O:
            if out[i] == SCAN_LIGHT and has_circ[start:i + 1].any():
                out[i] = SCAN_HEAVY
            start = i + 1
    return out


def insert_marks(plain: str, labels: dict[int, int]) -> str:
    """Write `_`/`^` after the letters given by {letter_ordinal: MAC_*} (production
    output format, identical to the old project's)."""
    nfc = unicodedata.normalize("NFC", plain)
    out = []
    ordinal = -1
    pending = None
    for ch in nfc:
        if pending is not None and not unicodedata.category(ch).startswith("M"):
            out.append(pending)
            pending = None
        out.append(ch)
        if _is_letter(ch):
            ordinal += 1
            if ordinal in labels:
                pending = "_" if labels[ordinal] == MAC_LONG else "^"
    if pending is not None:
        out.append(pending)
    return "".join(out)


def bracketize(plain: str, labels: dict[int, int]) -> str:
    """Render per-letter scan labels back into [heavy]{light} spans (verse-end span
    is emitted as heavy, matching brevis in longo display in the old corpus)."""
    nfc = unicodedata.normalize("NFC", plain)
    out, cur = [], []
    ordinal = -1
    for ch in nfc:
        cur.append(ch)
        if _is_letter(ch):
            ordinal += 1
            lab = labels.get(ordinal, SCAN_O)
            if lab != SCAN_O:
                o, c = ("{", "}") if lab == SCAN_LIGHT else ("[", "]")
                out.append(o + "".join(cur) + c)
                cur = []
    if cur:
        out.append("".join(cur))
    return "".join(out)