File size: 6,722 Bytes
5952424
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Edit scripts (form -> lemma) and the label vocabulary.

Scripts operate on *strings*, not the model's char planes: lowercased NFC with the
form-side folded so that graves become acutes and zero-information marks (macron,
breve, dot-below) are stripped. Accent shifts in inflection (ἀνθρώπου -> ἄνθρωπος)
are then literal prefix/suffix replacements, and applying a script yields the fully
accented lemma directly. Lemma-side strings keep everything except casing, which is
captured in a per-script capitalization bit.
"""
from __future__ import annotations

import json
import unicodedata
from collections import Counter
from dataclasses import dataclass, field

GRAVE, ACUTE = "̀", "́"
_STRIP_MARKS = {"̄", "̆", "̣"}   # macron, breve, dot-below

XPOS_LEN = 9


def form_key(s: str) -> str:
    """Fold a surface form for script/lexicon keys: lowercase, grave->acute, strip
    macron/breve/underdot. NFC output."""
    s = unicodedata.normalize("NFD", s.lower())
    s = s.replace(GRAVE, ACUTE)
    s = "".join(ch for ch in s if ch not in _STRIP_MARKS)
    return unicodedata.normalize("NFC", s)


def lemma_key(s: str) -> str:
    """Lemma side: lowercase + NFC only (macrons/homonym digits kept verbatim)."""
    return unicodedata.normalize("NFC", s.lower())


# Script = (p_cut, p_add, s_cut, s_add, cap) : lemma = p_add + form[p_cut:len-s_cut] + s_add
Script = tuple[int, str, int, str, bool]


def _longest_common_substring(a: str, b: str) -> tuple[int, int, int]:
    """(start_a, start_b, length) of the longest common substring; ties -> smallest
    start_a (align prefixes, since Greek inflection is mostly suffixal)."""
    best = (0, 0, 0)
    m = len(b)
    prev = [0] * (m + 1)
    for i, ca in enumerate(a):
        cur = [0] * (m + 1)
        for j, cb in enumerate(b):
            if ca == cb:
                cur[j + 1] = prev[j] + 1
                l = cur[j + 1]
                if l > best[2]:
                    best = (i - l + 1, j - l + 1, l)
        prev = cur
    return best


def compute_script(form: str, lemma: str) -> Script:
    """form: already form_key()-folded, lowercase. lemma: original casing, NFC."""
    cap = bool(lemma[:1]) and lemma[0] != lemma[0].lower()
    lem = lemma_key(lemma)
    ia, ib, l = _longest_common_substring(form, lem)
    if l == 0:
        return (len(form), lem, 0, "", cap)
    return (ia, lem[:ib], len(form) - ia - l, lem[ib + l:], cap)


def apply_script(form: str, sc: Script) -> str | None:
    p_cut, p_add, s_cut, s_add, _cap = sc
    if len(form) < p_cut + s_cut:
        return None
    return p_add + form[p_cut:len(form) - s_cut or None] + s_add


def script_str(sc: Script) -> str:
    return json.dumps(list(sc), ensure_ascii=False)


@dataclass
class LabelVocab:
    scripts: list[Script] = field(default_factory=list)
    xpos_alpha: list[list[str]] = field(default_factory=list)   # 9 per-position alphabets
    tags: list[str] = field(default_factory=list)               # attested full XPOS tags
    upos: list[str] = field(default_factory=list)
    # lexicon: form_key -> {"lemma\txpos": count} and form_key -> {lemma: count}
    lex_ft: dict = field(default_factory=dict)
    lex_f: dict = field(default_factory=dict)
    # rule table for unencodable (non-Greek) forms: raw form -> (lemma, upos, xpos)
    nongreek: dict = field(default_factory=dict)
    fallback_xpos: str = "u--------"
    fallback_upos: str = "u"

    def __post_init__(self):
        self._sid = {s: i for i, s in enumerate(self.scripts)}
        self._tagset = set(self.tags)
        self._tid = {t: i for i, t in enumerate(self.tags)}
        self._xid = [{c: i for i, c in enumerate(a)} for a in self.xpos_alpha]
        self._uid = {u: i for i, u in enumerate(self.upos)}

    # ---- ids (return -100 for unseen: ignored in the loss, counted in coverage)
    def script_id(self, sc: Script) -> int:
        return self._sid.get(sc, -100)

    def xpos_ids(self, tag: str) -> list[int]:
        tag = (tag or "-" * XPOS_LEN)[:XPOS_LEN].ljust(XPOS_LEN, "-")
        return [self._xid[p].get(c, -100) for p, c in enumerate(tag)]

    def upos_id(self, u: str) -> int:
        return self._uid.get(u, -100)

    def tag_id(self, tag: str) -> int:
        tag = (tag or "-" * XPOS_LEN)[:XPOS_LEN].ljust(XPOS_LEN, "-")
        return self._tid.get(tag, -100)

    @property
    def n_scripts(self):
        return len(self.scripts)

    # ---- build / io
    @classmethod
    def build(cls, sentences, encodable_fn) -> "LabelVocab":
        """sentences: iterable of conllu.Sentence. encodable_fn(form)->bool decides which
        tokens go through the neural path vs the non-Greek rule table."""
        scripts = Counter()
        pos_alpha = [set("-") for _ in range(XPOS_LEN)]
        tags = Counter()
        upos = Counter()
        lex_ft, lex_f = {}, {}
        nongreek = {}
        for sent in sentences:
            for t in sent.tokens:
                tag = (t.xpos or "-" * XPOS_LEN)[:XPOS_LEN].ljust(XPOS_LEN, "-")
                if not encodable_fn(t.form):
                    nongreek.setdefault(t.form, Counter())[(t.lemma, t.upos, tag)] += 1
                    continue
                key = form_key(t.form)
                sc = compute_script(key, t.lemma)
                scripts[sc] += 1
                for p, c in enumerate(tag):
                    pos_alpha[p].add(c)
                tags[tag] += 1
                upos[t.upos] += 1
                lex_ft.setdefault(key + "\t" + tag, Counter())[t.lemma] += 1
                lex_f.setdefault(key, Counter())[t.lemma] += 1
        return cls(
            scripts=[s for s, _ in scripts.most_common()],
            xpos_alpha=[sorted(a) for a in pos_alpha],
            tags=sorted(tags),
            upos=sorted(upos),
            lex_ft={k: dict(c) for k, c in lex_ft.items()},
            lex_f={k: dict(c) for k, c in lex_f.items()},
            nongreek={f: c.most_common(1)[0][0] for f, c in nongreek.items()},
        )

    def save(self, path):
        d = dict(scripts=[list(s) for s in self.scripts], xpos_alpha=self.xpos_alpha,
                 tags=self.tags, upos=self.upos, lex_ft=self.lex_ft, lex_f=self.lex_f,
                 nongreek=self.nongreek, fallback_xpos=self.fallback_xpos,
                 fallback_upos=self.fallback_upos)
        with open(path, "w", encoding="utf-8") as f:
            json.dump(d, f, ensure_ascii=False)

    @classmethod
    def load(cls, path) -> "LabelVocab":
        with open(path, encoding="utf-8") as f:
            d = json.load(f)
        d["scripts"] = [tuple(s[:4]) + (bool(s[4]),) for s in d["scripts"]]
        d["nongreek"] = {k: tuple(v) for k, v in d["nongreek"].items()}
        return cls(**d)