File size: 13,398 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
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
"""Elastic masked-diffusion noising (plan §4).

Given a packed batch of char sequences with aligned boundary planes, produce:
  input_ids   corrupted char ids (MASK where noised)
  labels      target char ids at supervised positions, -100 elsewhere
  loss_w      per-position loss weight (MDLM 1/t reweighting, averaged over the batch)
  keep_bnd_mask  per-POSITION bool: is the boundary channel known as input here
  keep_dia_mask  per-POSITION bool: is the diacritic channel known as input here

Noise patterns, chosen per sequence by a mixture (weights configurable), rate t ~ [0.05, 0.95]:
  span      contiguous char run, boundary-agnostic (geometric length) -> the realistic damage
            shape: a broken stone or torn papyrus doesn't respect word edges.
  word      whole word(s) via boundary plane, frequency-weighted so stopwords don't dominate.
  halfword  a PARTIAL word, anchored to its beginning, middle, or end (weighted toward the end:
            Greek is heavily suffixal, so word-final spans train ending/inflection restoration
            directly; word-initial spans cover augments/prefixes; medial spans cover internal
            damage, e.g. a lost dichronon).
  elastic   a word span replaced by a VARIABLE number of MASK slots (true length L ->
            M in [L, ceil(1.3L)+2]); targets are the L chars followed by (M-L) BLANK(∅) tokens.
            Teaches variable-length infilling + a gap-length signal, with no decoder.
  iid       independent per-char masking at rate t.

The elastic pattern changes sequence length, so noising returns a NEW packed batch
(lengths differ from the input); the collate step must therefore run noising before
building attention/segment masks. Everything here is pure PyTorch, no CUDA specifics,
so it is testable on CPU.

Channel availability (boundary/diacritics) is noised INDEPENDENTLY of char masking, per
CHARACTER POSITION (not per whole sequence): a mixture of fully-known, fully-unknown, and
genuinely patchy (rate ~Uniform(0,1) applied per position) — modeling real epigraphic/
papyrological cases where SOME word-breaks are legible and others are damaged, or SOME
accents survive and others don't, within the same text.
"""
from __future__ import annotations

from dataclasses import dataclass, field

import torch

# reserved id layout (must match model embedding table)
#   0..V-1        char ids (V = alphabet size, currently 24)
#   MASK, BLANK, PAD are appended after the alphabet
@dataclass
class NoiseConfig:
    vocab: int = 24
    mask_id: int = 24
    blank_id: int = 25          # ∅ : "gap ends / no char here"
    pad_id: int = 26
    # mixture weights over patterns (need not sum to 1; normalized). pat index: 0=span,
    # 1=word, 2=elastic, 3=iid, 4=halfword, 5=substitute.
    w_span: float = 0.15
    w_word: float = 0.20
    w_elastic: float = 0.15
    w_iid: float = 0.15
    w_halfword: float = 0.20
    w_substitute: float = 0.15  # DENOISING: scattered chars replaced by a WRONG letter (no
                                # mask signal) — real repaired-OCR/scribal text has substitution
                                # errors, not just gaps; the model must catch+correct these too.
    # rate distribution: t ~ Beta(a,b) clipped to [t_min, t_max]
    t_min: float = 0.05
    t_max: float = 0.95
    beta_a: float = 2.0
    beta_b: float = 4.0         # mean ~0.33, mass in the 0.15-0.45 encoder-quality band
    span_mean: float = 3.5      # geometric mean length for span/word-ish spans
    span_max: int = 20
    elastic_pad_frac: float = 0.3   # M = L + Bernoulli-ish extra up to ceil(elastic_pad_frac*L)+2
    elastic_extra_min: int = 2
    # halfword anchor mixture: weighted toward word-END (Greek inflection is suffixal)
    halfword_end_p: float = 0.5
    halfword_begin_p: float = 0.3
    halfword_mid_p: float = 0.2
    # per-position channel-availability mixture: P(fully known) + P(fully unknown) + the
    # remainder is genuinely patchy (per-position Bernoulli at a rate ~ Uniform(0,1))
    p_bnd_full: float = 0.5
    p_bnd_none: float = 0.3
    p_dia_full: float = 0.3
    p_dia_none: float = 0.5
    p_punct_full: float = 0.3
    p_punct_none: float = 0.5
    # per-DOCUMENT (not per-position) metadata dropout: real region/century values are only
    # sometimes known for a real fragment (unprovenanced papyri, no surviving date), so the
    # model must be trained under both conditions to be usable in either at inference. Default
    # 0.0 preserves every existing caller's exact behavior (metadata conditioning off unless
    # a caller explicitly sets these).
    p_region_none: float = 0.0
    p_century_none: float = 0.0


def _sample_t(cfg, n, g):
    a = torch.full((n,), cfg.beta_a)
    b = torch.full((n,), cfg.beta_b)
    t = torch.distributions.Beta(a, b).sample()
    return t.clamp(cfg.t_min, cfg.t_max)


def _mdlm_weight(t):
    # MDLM continuous-time weight ~ 1/t (clamped); normalized later per batch
    return (1.0 / t.clamp_min(0.05))


def _sample_keep_mask(n, g, p_full, p_none):
    """Per-position channel-availability mask (True = known/kept at this position). Mixture of
    fully-known, fully-unknown, and patchy (rate ~ Uniform(0,1), independent per position) —
    the patchy case is what lets one sequence have SOME boundaries/accents known and others not."""
    u = torch.rand(1, generator=g).item()
    if u < p_full:
        return torch.ones(n, dtype=torch.bool)
    if u < p_full + p_none:
        return torch.zeros(n, dtype=torch.bool)
    rate = torch.rand(1, generator=g).item()
    return torch.rand(n, generator=g) < rate


def _pick_halfword_span(s, e, cfg, g):
    """s,e: char range [s,e) of one word. Returns a sub-span anchored to the word's beginning,
    middle, or end (weighted toward the end — see module docstring)."""
    L = e - s
    if L <= 1:
        return s, e
    u = torch.rand(1, generator=g).item()
    anchor = "end" if u < cfg.halfword_end_p else (
             "begin" if u < cfg.halfword_end_p + cfg.halfword_begin_p else "mid")
    l = int(torch.randint(1, L + 1, (1,), generator=g).item())
    if anchor == "begin":
        return s, s + l
    if anchor == "end":
        return e - l, e
    if L <= 2:                       # too short for a non-edge-touching middle span
        return s, e
    l = max(1, min(l, L - 2))
    off = int(torch.randint(1, L - l, (1,), generator=g).item()) if L - l > 1 else 1
    return s + off, s + off + l


def noise_sequence(chars, boundary, cfg: NoiseConfig, g: torch.Generator, is_real_lacuna=None):
    """Corrupt ONE sequence (1D LongTensor chars, 1D boundary). Returns dict of 1D tensors.

    is_real_lacuna: optional bool tensor, same length as chars. Marks positions where the
    true content is GENUINELY unknown (a real '-'/'...' run from the edition itself --
    see insc/data/iphi.py's/papyri.py's text_to_full_planes()), already fed as mask_id in
    `chars`. When given, these positions are never eligible to be chosen as an ADDITIONAL
    synthetic-masking target (there's nothing there to mask further, and `chars` at those
    positions is mask_id, not a real letter -- treating it as one would produce a nonsense
    label) and their label always stays -100. None (default) preserves every existing
    caller's behavior exactly -- this is purely additive."""
    n = chars.numel()
    device = chars.device
    t = _sample_t(cfg, 1, g).item()

    # choose pattern: 0=span, 1=word, 2=elastic, 3=iid, 4=halfword, 5=substitute
    weights = torch.tensor([cfg.w_span, cfg.w_word, cfg.w_elastic, cfg.w_iid, cfg.w_halfword,
                           cfg.w_substitute], dtype=torch.float)
    pat = torch.multinomial(weights, 1, generator=g).item()

    inp = chars.clone()
    lab = torch.full((n,), -100, dtype=torch.long, device=device)

    def mask_positions(pos):
        inp[pos] = cfg.mask_id
        lab[pos] = chars[pos]

    def substitute_positions(pos):
        # DENOISING: replace with a WRONG letter (never MASK) — no signal that it's corrupted.
        wrong = (chars[pos] + torch.randint(1, cfg.vocab, (len(pos),), generator=g,
                                            device=device)) % cfg.vocab
        inp[pos] = wrong
        lab[pos] = chars[pos]

    if pat == 3:  # iid (mask)
        m = torch.rand(n, generator=g, device=device) < t
        if is_real_lacuna is not None:
            m = m & ~is_real_lacuna
        if m.any():
            mask_positions(m.nonzero(as_tuple=True)[0])
        return _finish(inp, lab, chars, boundary, t, cfg, g)

    if pat == 5:  # substitute (denoise) — same scattered selection as iid, no mask token
        m = torch.rand(n, generator=g, device=device) < t
        if is_real_lacuna is not None:
            m = m & ~is_real_lacuna
        if m.any():
            substitute_positions(m.nonzero(as_tuple=True)[0])
        return _finish(inp, lab, chars, boundary, t, cfg, g)

    # span/word/elastic/halfword all pick target spans until ~t fraction of chars covered
    word_ends = (boundary >= 1).nonzero(as_tuple=True)[0]
    # word start indices
    starts = torch.cat([torch.tensor([0], device=device), word_ends[:-1] + 1])
    words = list(zip(starts.tolist(), (word_ends + 1).tolist())) if word_ends.numel() else [(0, n)]

    budget = int(t * n)
    covered = 0
    chosen_spans = []
    tries = 0
    # pre-mark real-lacuna positions as "used" -- span selection below already rejects any
    # candidate overlapping a used position, so this alone keeps every chosen span entirely
    # within genuinely-known text without any extra branching in the selection loop.
    used = (is_real_lacuna.clone() if is_real_lacuna is not None
            else torch.zeros(n, dtype=torch.bool, device=device))
    while covered < budget and tries < 4 * len(words) + 8:
        tries += 1
        if pat == 0:  # span: random contiguous run, boundary-agnostic
            L = min(int(torch.distributions.Geometric(1.0 / cfg.span_mean).sample().item()) + 1,
                    cfg.span_max)
            s = int(torch.randint(0, max(n - L, 1), (1,), generator=g).item())
            e = min(s + L, n)
        elif pat == 4:  # halfword: partial word, anchored begin/middle/end
            wi = int(torch.randint(0, len(words), (1,), generator=g).item())
            ws, we = words[wi]
            s, e = _pick_halfword_span(ws, we, cfg, g)
        else:         # word / elastic: pick a whole word
            wi = int(torch.randint(0, len(words), (1,), generator=g).item())
            s, e = words[wi]
        if s >= e or used[s:e].any():
            continue
        used[s:e] = True
        chosen_spans.append((s, e))
        covered += e - s

    if pat in (0, 1, 4):  # span / word / halfword: fixed-length mask
        for s, e in chosen_spans:
            mask_positions(torch.arange(s, e, device=device))
        return _finish(inp, lab, chars, boundary, t, cfg, g)

    # pat == 2 elastic: rebuild the sequence with variable mask runs. chosen_spans is already
    # guaranteed lacuna-free (see `used` above); every other position -- including any real
    # lacuna -- is copied through unchanged with label -100 by _elastic_rebuild's own walk,
    # so no further is_real_lacuna handling is needed once the sequence is rebuilt.
    return _elastic_rebuild(chars, boundary, chosen_spans, t, cfg, g)


def _elastic_rebuild(chars, boundary, spans, t, cfg, g):
    device = chars.device
    n = chars.numel()
    spanset = sorted(spans)
    out_in, out_lab, out_bnd = [], [], []
    i = 0
    span_i = 0
    while i < n:
        if span_i < len(spanset) and i == spanset[span_i][0]:
            s, e = spanset[span_i]; span_i += 1
            L = e - s
            extra = cfg.elastic_extra_min + int(
                torch.randint(0, int(cfg.elastic_pad_frac * L) + 1, (1,), generator=g).item())
            M = L + extra
            out_in.append(torch.full((M,), cfg.mask_id, dtype=torch.long, device=device))
            lab = torch.full((M,), -100, dtype=torch.long, device=device)
            lab[:L] = chars[s:e]
            lab[L:] = cfg.blank_id          # predict ∅ for the surplus slots
            out_lab.append(lab)
            # boundary target for the gap: internal until the real word-end, unknown handled by model
            b = torch.zeros(M, dtype=torch.uint8, device=device)
            b[L - 1] = boundary[e - 1]
            out_bnd.append(b)
            i = e
        else:
            out_in.append(chars[i:i + 1])
            out_lab.append(torch.tensor([-100], device=device))
            out_bnd.append(boundary[i:i + 1])
            i += 1
    inp = torch.cat(out_in)
    lab = torch.cat(out_lab)
    bnd = torch.cat(out_bnd)
    return _finish(inp, lab, chars, bnd, t, cfg, g, rebuilt=True)


def _finish(inp, lab, orig_chars, boundary, t, cfg, g, rebuilt=False):
    n = inp.numel()
    keep_bnd_mask = _sample_keep_mask(n, g, cfg.p_bnd_full, cfg.p_bnd_none)
    keep_dia_mask = _sample_keep_mask(n, g, cfg.p_dia_full, cfg.p_dia_none)
    keep_punct_mask = _sample_keep_mask(n, g, cfg.p_punct_full, cfg.p_punct_none)
    w = _mdlm_weight(torch.tensor(t))
    return dict(input_ids=inp, labels=lab, boundary=boundary,
                loss_w=torch.full_like(inp, float(w), dtype=torch.float),
                keep_bnd_mask=keep_bnd_mask, keep_dia_mask=keep_dia_mask,
                keep_punct_mask=keep_punct_mask, t=t, rebuilt=rebuilt)