File size: 8,907 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
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
"""Aeneas-architecture Greek model (DeepMind `predictingthepast`, Nature 2025) on
our two frozen comparison sets.

Two modes, matching the two tables the paper compares systems in:

  --samples <strict_test_fold3_samples.json>   STRICT protocol: the same frozen
      3,000-sample file every other system reads. Gap = '?'*L at [start,start+L),
      spaces count as characters, predictions keep spaces, sigma/koppa folded,
      Levenshtein CER + exact top-1/top-20. Mirrors ithaca_baseline.run_strict.

  --dsh <inscr_text_recent.jsonl>              RECENT (uncontaminated) set: the
      DSH-41(1)-comparison release format ('[N letters missing]', N counts letters only).
      Scored with the verified port of their metric (difflib similarity ratio,
      normalize = fold final sigma + strip ' .·0', truncate hypothesis).

Restoration only; the model's retrieval/attribution capabilities are unused.
beam_width=20 in both modes to match every other system in both tables.

The model has a fixed context of 768 characters (inference.TEXT_LEN); longer
inputs are cropped symmetrically around the gap, mirroring the old harness.

  .venv-ptp/bin/python insc_eval/ptp_baseline.py --ckpt models/ithaca_153143996_2.pkl \
      --samples ../strict_test_fold3_samples.json --out out.json --shard 0,40
"""
from __future__ import annotations

import argparse, difflib, json, os, re, time
from pathlib import Path

import numpy as np

STRICT_FOLD = {"ς": "σ", "ϲ": "σ", "ϙ": "κ", "ϛ": "σ"}
GAP_RE = re.compile(r"\[(\d+) letters? missing\]")
CTX = 750  # < inference.TEXT_LEN with margin for SOS/padding


def canon(s):
    return "".join(STRICT_FOLD.get(c, c) for c in s)


def levenshtein(a, b):
    if a == b:
        return 0
    if not a:
        return len(b)
    if not b:
        return len(a)
    prev = list(range(len(b) + 1))
    for i, ca in enumerate(a, 1):
        cur = [i]
        for j, cb in enumerate(b, 1):
            cur.append(min(prev[j] + 1, cur[-1] + 1, prev[j - 1] + (ca != cb)))
        prev = cur
    return prev[-1]


# ---- their metric, verified port (reproduces released per-record CERs exactly)
def dsh_norm(s):
    return re.sub(r"[ \.·0]", "", s.replace("ς", "σ"))


def dsh_cer(ref, hyp):
    ref = dsh_norm(ref)
    hyp = dsh_norm(hyp)[: len(ref)]
    return 1 - difflib.SequenceMatcher(None, ref, hyp).ratio()


# ---------------------------------------------------------------- model
G = {}


def init(ckpt):
    # Mirrors inference_example.load_checkpoint(path, 'greek') exactly.
    import pickle

    import jax
    from predictingthepast.eval import inference
    from predictingthepast.models.model import Model
    from predictingthepast.util import alphabet as util_alphabet

    with open(ckpt, "rb") as f:
        checkpoint = pickle.load(f)
    params = jax.device_put(checkpoint["params"])
    model = Model(**checkpoint["model_config"])
    G.update(inference=inference, forward=model.apply, params=params,
             alphabet=util_alphabet.GreekAlphabet(),
             vocab=checkpoint["model_config"]["vocab_char_size"])


def crop(text, s, L):
    """Center a window of <=CTX chars on the gap [s, s+L)."""
    if len(text) <= CTX:
        return text, s
    half = (CTX - L) // 2
    lo = max(0, s - half)
    hi = min(len(text), lo + CTX)
    lo = max(0, hi - CTX)
    return text[lo:hi], s - lo


def restore(text, beam):
    r = G["inference"].restore(
        text, forward=G["forward"], params=G["params"], alphabet=G["alphabet"],
        vocab_char_size=G["vocab"], beam_width=beam)
    hyps = []
    for p in r.predictions[:beam]:
        idx = p.restored if p.restored else r.missing
        hyps.append("".join(p.text[i] for i in idx if i < len(p.text)))
    if not hyps and r.top_prediction:
        hyps = ["".join(r.top_prediction[i] for i in r.missing
                        if i < len(r.top_prediction))]
    return hyps


# ---------------------------------------------------------------- modes
def run_strict(a):
    samples = json.loads(Path(os.path.expandvars(a.samples)).read_text())
    if a.lengths:
        want = {int(x) for x in a.lengths.split(",")}
        samples = [s for s in samples if s["L"] in want]
    if a.shard:
        i, k = (int(x) for x in a.shard.split(","))
        samples = samples[i::k]
    print(f"{len(samples)} strict samples", flush=True)
    rows, t0, n_err = {}, time.time(), 0
    for i, s in enumerate(samples):
        text = s["text"][:s["start"]] + "?" * s["L"] + s["text"][s["start"] + s["L"]:]
        text, _ = crop(text, s["start"], s["L"])
        gold = canon(s["gold"])
        try:
            hyps = [canon(h) for h in restore(text, a.beam)]
        except Exception as e:
            n_err += 1
            hyps = []
            print(f"  ERR at {i}: {str(e)[:80]}", flush=True)
        r = rows.setdefault(s["L"], dict(n=0, cers=[], t1=0, t20=0))
        if hyps:
            r["cers"].append(levenshtein(hyps[0], gold) / max(len(gold), 1))
            r["t1"] += int(hyps[0] == gold)
            r["t20"] += int(any(h == gold for h in hyps))
            r["n"] += 1
        if (i + 1) % 25 == 0:
            print(f"  {i+1}/{len(samples)} ({(time.time()-t0)/(i+1):.1f}s/sample)",
                  flush=True)
    out = dict(model="aeneas_greek_2025", protocol="strict", errors=n_err,
               per_L={L: dict(n=r["n"], CER=round(float(np.mean(r["cers"])), 4),
                              top1=round(r["t1"] / max(r["n"], 1), 4),
                              top20=round(r["t20"] / max(r["n"], 1), 4))
                      for L, r in sorted(rows.items())})
    Path(a.out).write_text(json.dumps(out, indent=1))
    print("wrote", a.out, flush=True)


def run_dsh(a):
    rows_in = [json.loads(l) for l in open(os.path.expandvars(a.dsh))]
    if a.shard:
        i, k = (int(x) for x in a.shard.split(","))
        rows_in = rows_in[i::k]
    print(f"{len(rows_in)} recent-set samples", flush=True)
    per, t0, n_err = {}, time.time(), 0
    for i, rec in enumerate(rows_in):
        msgs = {m["role"]: m["content"] for m in rec["messages"]}
        user, gold = msgs.get("user", ""), msgs.get("assistant", "")
        m = GAP_RE.search(user)
        if not m or not gold:
            continue
        L = int(m.group(1))
        if not 1 <= L <= 10:
            continue
        # Gap construction copied from their own eval_ithaca_text.py: the number
        # of '?' slots is len(gold) -- the FULL gold INCLUDING SPACES -- not the
        # N letters of the placeholder. 58% of golds contain internal spaces
        # (388/398 at L=10); allocating only N slots leaves an Ithaca-style
        # model no room to emit both the word divisions and the letters, and
        # collapsed long-gap accuracy to ~1% in the first version of this
        # harness. Their released Ithaca predictions used this construction,
        # so mirroring it is also what makes the comparison symmetric.
        slots = len(gold)
        text = user[:m.start()] + "?" * slots + user[m.end():]
        text, _ = crop(text, m.start(), slots)
        try:
            hyps = restore(text, a.beam)
        except Exception as e:
            n_err += 1
            print(f"  ERR at {i}: {str(e)[:80]}", flush=True)
            continue
        gn = dsh_norm(gold)
        pred = hyps[0] if hyps else ""
        r = per.setdefault(L, dict(n=0, cers=[], t1=0, t20=0))
        r["cers"].append(dsh_cer(gold, pred))
        r["t1"] += int(dsh_norm(pred)[: len(gn)] == gn)
        r["t20"] += int(any(dsh_norm(h)[: len(gn)] == gn for h in hyps[:20]))
        r["n"] += 1
        if (i + 1) % 25 == 0:
            print(f"  {i+1}/{len(rows_in)} ({(time.time()-t0)/(i+1):.1f}s/sample)",
                  flush=True)
    out = dict(model="aeneas_greek_2025", protocol="dsh_recent",
               scoring="DSH2026 difflib-ratio", errors=n_err,
               per_L={L: dict(n=r["n"], CER=round(float(np.mean(r["cers"])), 4),
                              top1=round(r["t1"] / max(r["n"], 1), 4),
                              top20=round(r["t20"] / max(r["n"], 1), 4))
                      for L, r in sorted(per.items())})
    Path(a.out).write_text(json.dumps(out, indent=1))
    print("wrote", a.out, flush=True)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--ckpt", required=True)
    ap.add_argument("--samples", default=None, help="strict frozen samples json")
    ap.add_argument("--dsh", default=None, help="recent-set jsonl (DSH 2026)")
    ap.add_argument("--out", required=True)
    ap.add_argument("--beam", type=int, default=20)
    ap.add_argument("--lengths", default="")
    ap.add_argument("--shard", default=None)
    a = ap.parse_args()
    init(os.path.expandvars(a.ckpt))
    if a.samples:
        run_strict(a)
    elif a.dsh:
        run_dsh(a)
    else:
        raise SystemExit("need --samples or --dsh")


if __name__ == "__main__":
    main()