File size: 29,510 Bytes
e2458ca
 
 
 
 
 
da17d2f
 
 
 
 
e2458ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da17d2f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e2458ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da17d2f
e2458ca
 
da17d2f
 
 
 
 
 
 
 
 
 
e2458ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da17d2f
e2458ca
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
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
"""HF-Hub-compatible processor for CharDiff-grc-tagger-parser.

Turns one or more already word-tokenized sentences into the model's input tensors (the same
four character planes as the base CharBertProcessor, plus a `word_id` array pooling characters
into words, a `cap` plane -- an actual model input for this fine-tuned checkpoint, unlike the
base model where capitalization is output-only -- and a `seg_id` plane), and decodes the
model's raw logits back into predictions. The model's NATIVE tag/relation inventory is the
Perseus/AGDT scheme (9-position morphological tags; Prague-style dependency relations like
SBJ/OBJ/ATR/COORD/AuxP -- the scheme its OGA training treebank uses), not Universal
Dependencies. Pass `ud=True` to `decode()` for Universal Dependencies-style output instead
(UPOS/FEATS/deprel) -- see `to_ud()` below for the conversion and its scope/limitations.

Interface: call the processor with `List[str]` (one already-tokenized sentence, e.g.
`["ὁ", "ἄνθρωπος", "τρέχει", "."]`) or `List[List[str]]` (a batch of such sentences) --
i.e. tokens are CoNLL-U-style FORMs, punctuation already split into its own element. This
mirrors tagger/dataset.py's `sent.tokens` convention directly (each token's FORM is encoded
independently) without pulling in a real Greek word tokenizer as a dependency; a convenience
`.tokenize(text)` staticmethod is provided for plain-text input (naive whitespace split +
peeling off trailing sentence punctuation), which is enough for a usage-example sentence but
is not a substitute for real tokenization on running text.

Not a `PreTrainedTokenizer` subclass, for the same reason as the base CharBertProcessor: the
underlying encoding is parallel per-letter planes, not a single token-id stream. Follows the
same `register_for_auto_class` mechanism, so `AutoProcessor.from_pretrained(repo_id,
trust_remote_code=True)` works the same way.
"""
from __future__ import annotations

import json
import re
import unicodedata
from pathlib import Path

import numpy as np
import torch

PAD_ID = 26
UNK_BND, UNK_DIA, UNK_PUNCT = 3, 48, 6

ALPHABET = "αβγδεζηθικλμνξοπρστυφχψω"
LETTER_IDS = {c: i for i, c in enumerate(ALPHABET)}
ID2LETTER = np.array(list(ALPHABET))

_EXTRA_BASE = {
    "ς": "σ", "ϲ": "σ", "Ϲ": "σ", "ϐ": "β", "ϑ": "θ", "ϕ": "φ", "ϰ": "κ", "ϱ": "ρ", "ϖ": "π",
}
_MARK_MAP = {
    0x0301: "acute", 0x0341: "acute", 0x0300: "grave", 0x0340: "grave",
    0x0342: "circ", 0x0302: "circ", 0x0313: "smooth", 0x0343: "smooth",
    0x0314: "rough", 0x0345: "iota", 0x0308: "diaer",
}
_ACC = {"acute": 1, "grave": 2, "circ": 3}
_BR = {"smooth": 1, "rough": 2}
_PUNCT_CLASS = {",": 1, "·": 2, "·": 2, ":": 3}   # small punct-plane LUT (comma/mid-dot/colon);
                                                    # sentence-final .;!? are captured via the
                                                    # boundary plane instead, same convention as
                                                    # processing_char_bert.py's base processor.


def _pack_dia(acc, br, iota, diaer):
    return ((acc * 3 + br) * 2 + iota) * 2 + diaer


# ==== Universal Dependencies conversion ========================================================
#
# The model's native output is the Perseus/AGDT annotation scheme its OGA training treebank
# uses: a 9-position morphological tag (pos.person.number.tense.mood.voice.gender.case.degree,
# one letter per position, "-" for n/a) and Prague-style dependency relations (SBJ, OBJ, ATR,
# ADV, PNOM, PRED, APOS, COORD, Aux*, ATV/AtvV, ExD, MWE, OCOMP) -- NOT Universal Dependencies.
#
# This is a solved, documented conversion problem, not something designed from scratch here:
# both UD_Ancient_Greek-Perseus and UD_Ancient_Greek-PROIEL are themselves automatic
# conversions of exactly this annotation style, via Zeman & Ramasamy's HamleDT pipeline
# (Zeman et al. 2014, "HamleDT: Harmonized Multi-Language Dependency Treebank", LRE) --
# specifically Treex::Block::HamleDT::{GRC::Harmonize,HarmonizePerseus,Udep} and
# Treex::Tool::PhraseBuilder::PragueToUD (github.com/ufal/treex), plus the morphology decoder
# Lingua::Interset::Tagset::GRC::Conll (github.com/dan-zeman/interset). The tables and
# restructuring rules below port that design to our exact tag/relation inventory.
#
# Two relations require real TREE restructuring, not just a label rename, because UD attaches
# function words/conjuncts differently than Prague style:
#   - COORD: Prague style makes the coordinating conjunction the head, conjuncts its children.
#     UD makes the FIRST conjunct the head, the conjunction becomes a `cc` dependent of it, and
#     other conjuncts become `conj` dependents.
#   - AuxP: Prague style makes the preposition the head of its complement. UD reverses this:
#     the nominal complement becomes the head, the preposition becomes a `case` dependent of it.
#   - Pnom (predicate nominal / copula): Prague style makes the copula verb the head with the
#     nominal attached as Pnom. UD makes the NOMINAL the head, the copula a `cop` dependent,
#     and reattaches the subject to the nominal instead of the verb.
#
# What relation does the PROMOTED node (first conjunct / nominal complement) get in UD, given
# it used to be a plain dependent of COORD/AuxP? In full AGDT/HamleDT, the coordinator's or
# preposition's own outbound relation carries the phrase's true external function via a
# _CO/_AP label suffix (e.g. "Sb_Co"), which HamleDT strips off and hands to the promoted node.
# Our 24-label inventory has no such suffix mechanism -- verified directly against real training
# examples in oga_sota.conllu (e.g. "ἀπὸ" and "ἕως" each attach to their external governor with
# deprel=AuxP verbatim, and "καὶ" attaches with deprel=COORD verbatim, regardless of whether the
# phrase functions as a locative adjunct, a coordinated subject, or anything else). So the label
# itself never carries a recoverable function here, and the promoted node gets UD's generic
# fallback instead of a falsely precise guess: "obl" for AuxP nominals, "dep" for COORD first
# conjuncts (see Stage 1/2 below).
#
# SCOPE: this ports the core, well-documented HamleDT/UD restructuring rules and a straight
# per-label relabeling for everything else. It does NOT replicate every Ancient-Greek-specific
# refinement in the full pipeline (e.g. GRC::Harmonize's regex-based negation-particle
# splitting, or the is_member/shared-modifier _CO/_AP distinction discussed above) -- treat this
# as a faithful, reasonably-scoped port, not a byte-exact reproduction of the official
# UD_Ancient_Greek-Perseus release.

# position 1 (POS) -> UD UPOS. 'c' defaults to CCONJ, refined to SCONJ if its converted deprel
# is "mark" (i.e. it was AuxC); 'v' stays VERB regardless of mood (participles etc. get
# VerbForm=Part as a feature, not a UPOS change, matching standard UD_Ancient_Greek practice).
_POS_TO_UPOS = {
    "a": "ADJ", "c": "CCONJ", "d": "ADV", "g": "PART", "i": "INTJ", "l": "DET",
    "m": "NUM", "n": "NOUN", "p": "PRON", "r": "ADP", "u": "PUNCT", "v": "VERB", "x": "X",
}
_NUMBER = {"d": "Dual", "p": "Plur", "s": "Sing"}
_TENSE_ASPECT = {   # (Tense, Aspect-or-None); aorist/perfect/future-perfect analysis per
    "a": ("Past", "Perf"),   # aorist = perfective past
    "f": ("Fut", None),
    "i": ("Past", "Imp"),    # imperfect
    "l": ("Pqp", None),      # pluperfect
    "p": ("Pres", None),
    "r": ("Pres", "Perf"),   # perfect = present relevance of a completed action
    "t": ("Fut", "Perf"),    # future perfect
}
_MOOD_VERBFORM = {   # (Mood-or-None, VerbForm)
    "i": ("Ind", "Fin"), "s": ("Sub", "Fin"), "o": ("Opt", "Fin"), "m": ("Imp", "Fin"),
    "n": (None, "Inf"), "p": (None, "Part"), "g": (None, "Gdv"),
}
_VOICE = {"a": "Act", "m": "Mid", "p": "Pass", "e": "Mid"}   # e = mediopassive, approximated as Mid
_GENDER = {"c": "Com", "f": "Fem", "m": "Masc", "n": "Neut"}
_CASE = {"a": "Acc", "d": "Dat", "g": "Gen", "n": "Nom", "v": "Voc"}
_DEGREE = {"c": "Cmp", "s": "Sup"}


def xpos_to_upos_feats(xpos: str) -> tuple[str, dict]:
    """9-position Perseus/AGDT tag (e.g. "v3spia---") -> (UD UPOS, UD FEATS dict).
    "-" in any position means not applicable/unspecified and is simply omitted from FEATS."""
    pos = xpos[0] if xpos else "x"
    upos = _POS_TO_UPOS.get(pos, "X")
    feats = {}
    rest = xpos[1:].ljust(8, "-")
    person, number, tense, mood, voice, gender, case, degree = rest[:8]
    if person in "123":
        feats["Person"] = person
    if number in _NUMBER:
        feats["Number"] = _NUMBER[number]
    if tense in _TENSE_ASPECT:
        t, a = _TENSE_ASPECT[tense]
        feats["Tense"] = t
        if a:
            feats["Aspect"] = a
    if mood in _MOOD_VERBFORM:
        m, vf = _MOOD_VERBFORM[mood]
        if m:
            feats["Mood"] = m
        feats["VerbForm"] = vf
    if voice in _VOICE:
        feats["Voice"] = _VOICE[voice]
    if gender in _GENDER:
        feats["Gender"] = _GENDER[gender]
    if case in _CASE:
        feats["Case"] = _CASE[case]
    if degree in _DEGREE:
        feats["Degree"] = _DEGREE[degree]
    return upos, feats


# Straight per-label relabeling for everything that ISN'T COORD/AuxP/Pnom (those need the tree
# restructuring in to_ud() below). Homoglyph artifacts in the training treebank (Greek "Ζ"/"Κ"
# instead of Latin "Z"/"K" in a couple of AuxZ/AuxK instances) are normalized first.
_HOMOGLYPH_FIX = {"AuxΖ": "AuxZ", "AuxΚ": "AuxK"}
_SIMPLE_DEPREL = {
    "SBJ": "nsubj", "OBJ": "obj", "OCOMP": "obj", "APOS": "appos",
    "AuxC": "mark", "AuxV": "aux", "AuxG": "punct", "AuxK": "punct", "AuxX": "punct",
    "AuxR": "expl", "AuxY": "cc", "AuxZ": "advmod", "AuxΖ": "advmod", "AuxΚ": "punct",
    "ATV": "xcomp", "AtvV": "xcomp", "ExD": "orphan", "MWE": "fixed",
    "PRED": "root",
}


def _normalize_deprel(label: str | None) -> str | None:
    return _HOMOGLYPH_FIX.get(label, label)


def to_ud(sent: list[dict]) -> list[dict]:
    """Convert one sentence's native decode() output (list of {form, xpos, lemma, upos, head,
    deprel} dicts, 1-indexed word order, head=0 meaning root) into UD-style output: each dict
    becomes {form, lemma, upos (UD), xpos (native tag, kept as-is per UD convention that XPOS
    is language-specific and optional), feats (dict), head, deprel (UD)}.

    See the module-level comment above for the restructuring rules and scope/limitations.
    """
    n = len(sent)
    if n == 0:
        return []
    heads = [0] * (n + 1)
    deprels = [None] * (n + 1)
    native_upos = [None] * (n + 1)
    for i, w in enumerate(sent, start=1):
        heads[i] = w["head"] if w["head"] is not None else 0
        deprels[i] = _normalize_deprel(w["deprel"])
        native_upos[i] = w["upos"]

    def children_of():
        c = {i: [] for i in range(0, n + 1)}
        for i in range(1, n + 1):
            c.setdefault(heads[i], []).append(i)
        return c

    # Stage 1: COORD -> first-conjunct-head restructuring.
    for c in [i for i in range(1, n + 1) if deprels[i] == "COORD"]:
        kids = sorted(children_of().get(c, []))
        if not kids:
            continue
        first, rest = kids[0], kids[1:]
        heads[first] = heads[c]
        deprels[first] = "dep"   # COORD's own outbound label carries no recoverable function; see above
        heads[c] = first
        deprels[c] = "cc"
        for k in rest:
            heads[k] = first
            if deprels[k] not in ("AuxG", "AuxK", "AuxX", "AuxΖ", "AuxΚ"):  # not a punctuation child
                deprels[k] = "conj"
            # punctuation children just reattach to `first`, keeping their own (later-relabeled) deprel

    # Stage 2: AuxP -> case restructuring (nominal complement becomes the head).
    for p in [i for i in range(1, n + 1) if deprels[i] == "AuxP"]:
        kids = sorted(children_of().get(p, []))
        if not kids:
            continue
        nominal, others = kids[0], kids[1:]
        heads[nominal] = heads[p]
        deprels[nominal] = "obl"   # AuxP's own outbound label carries no recoverable function; see above
        heads[p] = nominal
        deprels[p] = "case"
        for o in others:
            heads[o] = nominal

    # Stage 3: Pnom (predicate nominal) -> copula reversal. The nominal becomes the head in
    # place of the copula verb; the verb becomes `cop`; anything else attached to the verb
    # (chiefly the subject) is reattached to the nominal.
    for nom in [i for i in range(1, n + 1) if deprels[i] == "PNOM"]:
        verb = heads[nom]
        if verb == 0:
            continue
        heads[nom] = heads[verb]
        deprels[nom] = deprels[verb] if deprels[verb] != "PRED" else "root"
        heads[verb] = nom
        deprels[verb] = "cop"
        for i in range(1, n + 1):
            if i != nom and heads[i] == verb:
                heads[i] = nom

    # Stage 4: everything else -- straight relabel, with a few UPOS/context-sensitive refinements.
    for i in range(1, n + 1):
        d = deprels[i]
        if d in ("cc", "conj", "case", "cop", "dep", "obl"):     # already converted above
            continue
        if d == "ATR":
            deprels[i] = ("det" if native_upos[i] == "l"
                          else "amod" if native_upos[i] == "a"
                          else "nmod")
        elif d == "ADV":
            deprels[i] = ("advmod" if native_upos[i] == "d"
                          else "advcl" if native_upos[i] == "v" else "obl")
        elif d in _SIMPLE_DEPREL:
            deprels[i] = _SIMPLE_DEPREL[d]
        # else: already UD-style (e.g. a second pass over an already-converted label) or
        # unrecognized -- left as-is rather than silently dropped.

    out = []
    for i, w in enumerate(sent, start=1):
        upos, feats = xpos_to_upos_feats(w["xpos"])
        if deprels[i] == "mark" and upos == "CCONJ":
            upos = "SCONJ"   # refine c (CCONJ default) once we know it introduced a subordinate clause
        out.append(dict(form=w["form"], lemma=w["lemma"], upos=upos, xpos=w["xpos"],
                         feats=feats, head=heads[i], deprel=deprels[i]))
    return out


# ---- edit-script machinery (tagger/edits.py, ported verbatim on strings) ---------------------

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


def form_key(s: str) -> str:
    """Fold a surface form for script/lexicon keys: lowercase, grave->acute, strip
    macron/breve/underdot. NFC output. (tagger.edits.form_key, verbatim.)"""
    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 apply_script(form: str, sc) -> str | None:
    """tagger.edits.apply_script, verbatim. sc = (p_cut, p_add, s_cut, s_add, cap)."""
    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


# ---- per-word character classification (subset of processing_char_bert.py's _classify) -------


def classify_word(word: str):
    """NFD-normalize `word`; return (chars, dia, cap) int lists, or None if it has no Greek
    letters (e.g. pure punctuation / digits / Latin -- these get no word slot, matching
    tagger/dataset.py's encode_word/encode_sentence convention)."""
    nfd = unicodedata.normalize("NFD", word)
    chars, dia, cap = [], [], []
    acc = br = iota = diaer = 0
    for ch in nfd:
        low = ch.lower()
        base = low if low in LETTER_IDS else _EXTRA_BASE.get(low)
        if base is not None:
            chars.append(LETTER_IDS[base])
            cap.append(1 if ch != low else 0)
            dia.append(0)
            acc = br = iota = diaer = 0
        elif unicodedata.combining(ch) or ord(ch) in _MARK_MAP:
            kind = _MARK_MAP.get(ord(ch))
            if kind in _ACC:
                acc = _ACC[kind]
            elif kind in _BR:
                br = _BR[kind]
            elif kind == "iota":
                iota = 1
            elif kind == "diaer":
                diaer = 1
            if dia:
                dia[-1] = _pack_dia(acc, br, iota, diaer)
        # anything else inside a single pre-tokenized word (stray punctuation) is dropped
    if not chars:
        return None
    return chars, dia, cap


def punct_class(tok: str) -> int:
    """Punctuation class a non-Greek token contributes to the preceding word's punct plane."""
    return max((_PUNCT_CLASS.get(c, 0) for c in tok), default=0)


def encode_sentence(tokens: list[str]):
    """tokens: pre-tokenized FORMs (CoNLL-U-style; punctuation already its own element).
    Returns (chars, boundary, dia, punct, cap, word_id, forms) for one packed sentence-row.
    `forms` holds the surface FORM of each *encodable* (has-Greek-letters) token, in word-slot
    order -- needed later to apply the lemma edit-script back onto real text."""
    chars, boundary, dia, punct, cap, word_id = [], [], [], [], [], []
    forms = []
    w = 0
    for tok in tokens:
        enc = classify_word(tok)
        if enc is None:
            pc = punct_class(tok)
            if pc and punct:
                punct[-1] = max(punct[-1], pc)
            continue
        c, d, cp = enc
        n = len(c)
        chars.extend(c)
        dia.extend(d)
        cap.extend(cp)
        punct.extend([0] * n)
        boundary.extend([0] * (n - 1) + [1])
        word_id.extend([w] * n)
        forms.append(tok)
        w += 1
    if boundary:
        boundary[-1] = 2   # sentence end
    return chars, boundary, dia, punct, cap, word_id, forms


_TRAIL_PUNCT_RE = re.compile(r"^(.*?)([.;!?,·:]+)$")


class CharBertJointProcessor:
    """`processor(sentences)` -> dict of batched tensors ready for
    `CharBertForTaggingAndParsing(**batch)`; `processor.decode(model_out, batch)` -> per-sentence
    list of word-level (form, xpos, lemma, upos, head, deprel) records."""

    def __init__(self, vocab: dict | None = None, deprel_vocab: list | None = None):
        self.vocab = vocab or {}
        self.deprel_vocab = deprel_vocab or []
        self.scripts = [tuple(s[:4]) + (bool(s[4]),) for s in self.vocab.get("scripts", [])]
        self.xpos_alpha = self.vocab.get("xpos_alpha", [])
        self.tags = self.vocab.get("tags", [])
        self.upos = self.vocab.get("upos", [])
        self.lex_ft = self.vocab.get("lex_ft", {})
        self.lex_f = self.vocab.get("lex_f", {})
        self.nongreek = self.vocab.get("nongreek", {})
        self.fallback_xpos = self.vocab.get("fallback_xpos", "u--------")
        self.fallback_upos = self.vocab.get("fallback_upos", "u")

        if self.xpos_alpha and self.tags:
            xid = [{c: i for i, c in enumerate(a)} for a in self.xpos_alpha]
            xpos_len = len(self.xpos_alpha)
            self.tag_idx = torch.tensor(
                [[xid[p].get(t[p] if p < len(t) else "-", 0) for p in range(xpos_len)]
                 for t in self.tags],
                dtype=torch.long,
            )
        else:
            self.tag_idx = None
        self._script_pc = np.array([s[0] + s[2] for s in self.scripts]) if self.scripts else None
        self._app_cache = {}

    # ---------------------------------------------------------------- construction

    @classmethod
    def from_pretrained(cls, path, **_kwargs):
        path = Path(path)
        vocab = json.loads((path / "vocab.json").read_text(encoding="utf-8"))
        deprel_vocab = json.loads((path / "deprel_vocab.json").read_text(encoding="utf-8"))
        return cls(vocab, deprel_vocab)

    def save_pretrained(self, save_directory, **_kwargs):
        save_directory = Path(save_directory)
        save_directory.mkdir(parents=True, exist_ok=True)
        (save_directory / "vocab.json").write_text(json.dumps(self.vocab, ensure_ascii=False))
        (save_directory / "deprel_vocab.json").write_text(json.dumps(self.deprel_vocab, ensure_ascii=False))
        (save_directory / "processor_config.json").write_text(
            json.dumps({"processor_class": "CharBertJointProcessor"}))

    @staticmethod
    def tokenize(text: str) -> list[str]:
        """Naive plain-text -> token-list convenience: whitespace split + peel a run of
        trailing sentence/clause punctuation off each whitespace-chunk into its own token.
        Good enough for a short usage-example sentence; real corpora should be tokenized
        properly (this is not a substitute for that)."""
        out = []
        for chunk in text.strip().split():
            m = _TRAIL_PUNCT_RE.match(chunk)
            if m and m.group(1):
                out.append(m.group(1))
                out.extend(list(m.group(2)))
            else:
                out.append(chunk)
        return out

    # ---------------------------------------------------------------- encode

    def __call__(self, sentences: list[str] | list[list[str]]):
        if not sentences:
            raise ValueError("sentences must be non-empty")
        if isinstance(sentences[0], str):
            sentences = [sentences]   # single sentence -> batch of 1

        encs = [encode_sentence(s) for s in sentences]
        B = len(encs)
        Tmax = max(len(e[0]) for e in encs)
        Tmax = max(Tmax, 1)

        ids = np.full((B, Tmax), PAD_ID, dtype=np.int64)
        bnd = np.zeros((B, Tmax), dtype=np.int64)
        dia = np.zeros((B, Tmax), dtype=np.int64)
        pct = np.zeros((B, Tmax), dtype=np.int64)
        cp = np.zeros((B, Tmax), dtype=np.int64)
        seg = np.zeros((B, Tmax), dtype=np.int64)
        wid = np.full((B, Tmax), -1, dtype=np.int64)
        all_forms = []
        for b, (c, bd, d, p, ca, wi, forms) in enumerate(encs):
            n = len(c)
            if n:
                ids[b, :n] = c
                bnd[b, :n] = bd
                dia[b, :n] = d
                pct[b, :n] = p
                cp[b, :n] = ca
                seg[b, :n] = 1
                wid[b, :n] = wi
            all_forms.append(forms)

        batch = dict(
            input_ids=torch.from_numpy(ids),
            boundary=torch.from_numpy(bnd),
            dia=torch.from_numpy(dia),
            punct=torch.from_numpy(pct),
            cap=torch.from_numpy(cp),
            seg_id=torch.from_numpy(seg),
            word_id=torch.from_numpy(wid),
        )
        batch["_forms"] = all_forms   # kept out-of-band: not a model input, needed for decoding
        return batch

    # ---------------------------------------------------------------- decode: xpos / upos

    def decode_xpos(self, xpos_logits, word_mask, flat_logits=None):
        """Constrained decode: combine the 9 factored heads' log-probs (+ the flat full-tag
        head's log-probs, if present) over the attested-tag inventory, argmax. Mirrors
        tagger.decode.TagDecoder.xpos exactly."""
        assert self.tag_idx is not None, "vocab.json (xpos_alpha/tags) required for xpos decode"
        ti = self.tag_idx.to(xpos_logits[0].device)
        score = 0
        for p, lg in enumerate(xpos_logits):
            lp = torch.log_softmax(lg.float(), -1)
            score = score + lp[:, :, ti[:, p]]
        if flat_logits is not None:
            score = score + torch.log_softmax(flat_logits.float(), -1)
        best = score.argmax(-1).cpu()
        return [[self.tags[int(best[b, w])]
                 for w in range(word_mask.shape[1]) if word_mask[b, w]]
                for b in range(word_mask.shape[0])]

    def decode_upos(self, upos_logits, word_mask):
        pred = upos_logits.argmax(-1).cpu()
        return [[self.upos[int(pred[b, w])]
                 for w in range(word_mask.shape[1]) if word_mask[b, w]]
                for b in range(word_mask.shape[0])]

    # ---------------------------------------------------------------- decode: lemma

    def _applicable(self, L):
        if L not in self._app_cache:
            self._app_cache[L] = self._script_pc <= L
        return self._app_cache[L]

    def decode_lemma(self, form: str, script_logprobs: torch.Tensor, xpos: str | None = None,
                      use_lexicon: bool = True, topk: int = 64) -> str:
        """script_logprobs: (n_scripts,) log-softmax for this word. Mirrors
        tagger.decode.LemmaDecoder.__call__ exactly (lexicon-rescored edit script, with an
        open-script fallback for OOV forms)."""
        key = form_key(form)
        copy_cap = form[:1] != form[:1].lower()

        app = torch.from_numpy(self._applicable(len(key))).to(script_logprobs.device)
        masked = script_logprobs.masked_fill(~app, -1e30)
        k = min(topk, masked.shape[-1])
        topv, topi = masked.topk(k)
        topv, topi = topv.tolist(), topi.tolist()

        if use_lexicon:
            cands = None
            if xpos is not None:
                cands = self.lex_ft.get(key + "\t" + xpos)
            if not cands:
                cands = self.lex_f.get(key, {})
            cands = dict(cands)
            if cands:
                lower = {}
                for lemma in cands:
                    lower.setdefault(lemma.lower(), lemma)
                best, best_s = None, -1e30
                for s, i in zip(topv, topi):
                    if s <= -1e29:
                        break
                    out = apply_script(key, self.scripts[i])
                    lemma = lower.get(out)
                    if lemma is not None:
                        sc = s + 1e-3 * np.log1p(cands[lemma])
                        if sc > best_s:
                            best, best_s = lemma, sc
                return best if best is not None else max(cands, key=cands.get)

        sc = self.scripts[topi[0]]
        lemma = apply_script(key, sc)
        if lemma is None:
            return form
        if sc[4] or copy_cap:
            lemma = lemma[:1].upper() + lemma[1:]
        return lemma

    # ---------------------------------------------------------------- decode: dependency arcs

    @staticmethod
    def decode_arcs(arc_scores, rel_scores):
        """Greedy per-token argmax head (0=root) + label argmax at the chosen head. Mirrors
        parser.biaffine.BiaffineHead.decode exactly."""
        heads_out = arc_scores.argmax(-1)
        B, W = heads_out.shape
        bi = torch.arange(B, device=arc_scores.device)[:, None].expand(B, W)
        wi = torch.arange(W, device=arc_scores.device)[None, :].expand(B, W)
        labels_out = rel_scores[bi, wi, heads_out].argmax(-1)
        return heads_out.cpu(), labels_out.cpu()

    # ---------------------------------------------------------------- full decode

    def decode(self, model_out, batch, use_lexicon: bool = True, ud: bool = False) -> list[list[dict]]:
        """model_out: a CharBertJointOutput (or the equivalent return_dict=False tuple).
        batch: the dict returned by __call__ (needs `_forms`).
        -> per sentence, a list of dicts, word order.

        Default (ud=False): the model's NATIVE Perseus/AGDT-style output --
        {form, xpos, lemma, upos, head, deprel}, where `upos` is the Perseus single-letter
        code (not UD) and `deprel` is a Prague-style relation (SBJ/OBJ/ATR/COORD/AuxP/...).

        ud=True: Universal Dependencies-style output instead -- {form, lemma, upos (UD),
        xpos (native tag, kept as-is per UD convention), feats (dict), head, deprel (UD)}.
        See `to_ud()` for the conversion (a documented, but not 100%-official-treebank-exact,
        port of the HamleDT/UD_Ancient_Greek-Perseus conversion methodology)."""
        word_mask = model_out.word_mask if hasattr(model_out, "word_mask") else model_out[-1]
        xpos_logits = model_out.xpos_logits if hasattr(model_out, "xpos_logits") else model_out[0]
        script_logits = model_out.script_logits if hasattr(model_out, "script_logits") else model_out[1]
        upos_logits = model_out.upos_logits if hasattr(model_out, "upos_logits") else model_out[2]
        flat_logits = model_out.flat_logits if hasattr(model_out, "flat_logits") else model_out[3]
        arc_scores = model_out.arc_scores if hasattr(model_out, "arc_scores") else model_out[4]
        rel_scores = model_out.rel_scores if hasattr(model_out, "rel_scores") else model_out[5]

        xpos_pred = self.decode_xpos(xpos_logits, word_mask, flat_logits=flat_logits)
        upos_pred = self.decode_upos(upos_logits, word_mask)
        script_logprobs = torch.log_softmax(script_logits.float(), -1)   # (B,W,n_script)

        heads_out, labels_out = (None, None)
        if arc_scores is not None:
            heads_out, labels_out = self.decode_arcs(arc_scores, rel_scores)

        forms_batch = batch["_forms"]
        results = []
        for b, forms in enumerate(forms_batch):
            n = len(forms)
            sent_out = []
            for w in range(n):
                form = forms[w]
                xpos = xpos_pred[b][w]
                upos = upos_pred[b][w]
                lemma = self.decode_lemma(form, script_logprobs[b, w], xpos=xpos, use_lexicon=use_lexicon)
                head = int(heads_out[b, w]) if heads_out is not None else None
                deprel = (self.deprel_vocab[int(labels_out[b, w])]
                          if labels_out is not None and self.deprel_vocab else None)
                sent_out.append(dict(form=form, xpos=xpos, lemma=lemma, upos=upos,
                                      head=head, deprel=deprel))
            results.append(to_ud(sent_out) if ud else sent_out)
        return results