File size: 7,253 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
"""STRICT protocol restoration eval for PAPYRI -- same methodology as restore_strict.py
(inscriptions), applied to whole DDbDP documents instead of pre-split segments.

Protocol (identical to restore_strict.py, no relaxations):
  - unit: L consecutive characters (letters and/or spaces) of the document's flattened
    text -- SPACES COUNT, exactly as for inscriptions. 8 letters + 2 word boundaries
    inside the gap = L10, not L8.
  - context: the WHOLE papyrus document (up to 3000 flattened chars; ctx=3200 letters
    in eval_ours guarantees every sample's full document fits in the model's window --
    no truncation to a local span around the gap).
  - our model predicts word division inside the gap (boundary head); division errors
    count in CER and break exact-match, same as inscriptions.
  - documents containing '…' (extent="unknown" gaps -- indeterminate length) are
    EXCLUDED entirely: their surrounding lacunae have no fixed width, which would make
    the "other lacunae are known-width unknowns" assumption invalid. Only '-' runs
    (known-width lost text) are kept as in-context lacunae, exactly like inscriptions.
  - unlike inscriptions' ithaca_text (already flattened: lowercase, accentless, only
    letters/space/'-'), papyri_clean.jsonl keeps real accents/case/extra punctuation
    (see clean_papyri.py). flatten() folds to the SAME reduced alphabet (24 base
    letters + space + '-') that restore_strict.py's text_to_planes()/eval_ours()
    already operate on, so all of its beam-search + scoring machinery is reused
    UNCHANGED -- this file only supplies papyri-specific data loading.

Modes (same CLI shape as restore_strict.py):
  --make-samples: python insc_eval/restore_strict_papyri.py --make-samples --split test \
      --n 300 --samples $INS_DATA/strict_test_samples_papyri.json
  --ckpt:         python insc_eval/restore_strict_papyri.py --ckpt <best.pt> \
      --samples $INS_DATA/strict_test_samples_papyri.json --out <out.json>
"""
from __future__ import annotations

import argparse, json, os, re, sys, unicodedata
from pathlib import Path

import numpy as np

sys.path.insert(1, str(Path(__file__).resolve().parents[1] / "data"))
sys.path.insert(2, str(Path(__file__).resolve().parent))
from data.normalize import ALPHABET
from restore_strict import eval_ours, levenshtein, canon  # reused verbatim

A_IDX = {c: i for i, c in enumerate(ALPHABET)}
PAP_JSONL = os.path.expandvars("$AGD_DATA/data/papyri_clean.jsonl")

# codepoints that fold onto the 24-letter alphabet (mirrors data/normalize.py's
# _EXTRA_BASE, kept local so this file has no hidden coupling beyond ALPHABET itself)
_EXTRA = {"ς": "σ", "ϲ": "σ", "Ϲ": "σ", "ϐ": "β", "ϑ": "θ", "ϕ": "φ",
          "ϰ": "κ", "ϱ": "ρ", "ϖ": "π"}
_PUNCT_TO_SPACE = str.maketrans({c: " " for c in "·,."})


def to_base_greek(ch):
    """Lowercase + NFD-strip accents + fold sigma/symbol variants -> one of the 24
    base letters (chars-plane alphabet), or None if not a modeled Greek letter."""
    lo = ch.lower()
    lo = _EXTRA.get(lo, lo)
    if lo in A_IDX:
        return lo
    dec = unicodedata.normalize("NFD", lo)
    base = _EXTRA.get(dec[0], dec[0]) if dec else ""
    return base if base in A_IDX else None


def flatten(text):
    """Fold a papyri_clean.jsonl 'text' string down to restore_strict.py's alphabet:
    24 base letters + space + '-' (known-width lacuna) only -- same vocabulary as
    inscriptions' ithaca_text, so text_to_planes()/eval_ours() need no changes."""
    t = text.translate(_PUNCT_TO_SPACE)
    out = []
    for ch in t:
        if ch in (" ", "-"):
            out.append(ch)
        else:
            b = to_base_greek(ch)
            out.append(b if b is not None else " ")
    flat = re.sub(r" +", " ", "".join(out)).strip()
    return flat


def split_of(tm):
    s = str(tm).strip()
    if not s or not s[-1].isdigit():
        return "train"
    test_d = os.environ.get("INSC_TEST_DIGIT", "3")
    val_d = os.environ.get("INSC_VAL_DIGIT", "4")
    return {val_d: "val", test_d: "test"}.get(s[-1], "train")


def load_papyri(split, exclude=None):
    """Whole papyri documents, flattened. Docs with '…' (unknown-width gap)
    dropped entirely (see module docstring)."""
    bad_tm = set()
    if exclude:
        j = json.loads(Path(os.path.expandvars(exclude)).read_text())
        bad_tm = {str(x[0]) for x in j["contaminated"] if str(x[0]) != "POSITIVE_CONTROL"}
    out = []
    with open(PAP_JSONL, encoding="utf-8") as f:
        for line in f:
            r = json.loads(line)
            tm = str(r.get("TM", ""))
            sp = split_of(tm)
            if sp != split or tm in bad_tm or "…" in (r.get("text") or ""):
                continue
            t = flatten(r.get("text") or "")
            if 50 <= len(t) <= 3000:
                out.append(dict(phi_id=tm, text=t))
    return out


def make_samples(split, n, lengths, exclude, out_path):
    recs = load_papyri(split, exclude)
    rng = np.random.default_rng(1234)
    rng.shuffle(recs)
    print(f"{len(recs)} whole papyri documents in {split} (decontaminated, "
          f"flattened, 50-3000 chars, no '…')")
    samples = []
    for L in lengths:
        lrng = np.random.default_rng(0 + L)
        tot = 0
        for r in recs:
            t = r["text"]
            if len(t) <= L + 8:
                continue
            s = int(lrng.integers(4, len(t) - L - 4))
            gap = t[s:s + L]
            if ("-" in gap or "  " in gap or gap.strip(" ") == "" or
                    (s > 0 and t[s - 1] == " " and gap[0] == " ") or
                    (t[s + L:s + L + 1] == " " and gap[-1] == " ")):
                continue
            samples.append(dict(phi_id=r["phi_id"], L=L, start=s, gold=gap, text=t))
            tot += 1
            if tot >= n:
                break
        print(f"L={L}: {tot} samples")
    Path(os.path.expandvars(out_path)).write_text(json.dumps(samples))
    print(f"wrote {len(samples)} samples -> {out_path}")


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--make-samples", action="store_true")
    ap.add_argument("--split", default="test")
    ap.add_argument("--n", type=int, default=300)
    ap.add_argument("--lengths", default="1,2,3,4,5,6,7,8,9,10")
    ap.add_argument("--exclude", default=None)
    ap.add_argument("--samples", required=True)
    ap.add_argument("--ckpt", default=None)
    ap.add_argument("--out", default=None)
    ap.add_argument("--beam", type=int, default=20)
    ap.add_argument("--ctx", type=int, default=3200,
                    help="letter-window width around the gap; >= max doc length (3000 "
                         "chars, fewer letters after flattening) guarantees full-document context")
    ap.add_argument("--shard", default=None, help="i,k -> process samples[i::k]")
    a = ap.parse_args()
    lengths = [int(x) for x in a.lengths.split(",")]
    if a.make_samples:
        make_samples(a.split, a.n, lengths, a.exclude, a.samples)
    elif a.ckpt:
        eval_ours(a.ckpt, a.samples, a.out, beam_width=a.beam, ctx=a.ctx,
                  lengths_filter=set(lengths), shard=a.shard)
    else:
        raise SystemExit("need --make-samples or --ckpt")


if __name__ == "__main__":
    main()