| """Annotated lines -> letter-plane records -> packed model batches. |
| |
| A record is one annotated unit (a macronized line, or a window of consecutive |
| verses) encoded through the backbone codec with per-letter label planes: |
| |
| y_mac -100 everywhere except marked dichrona (0 long / 1 short) |
| y_scan -100 for macron-only records; else 0 none / 1 heavy / 2 light / 3 verse |
| at every letter (the "no syllable ends here" class is supervised too) |
| |
| Records are greedily packed into fixed-T rows with per-record seg_ids, exactly like |
| pretraining's document packing (block-diagonal attention). All input planes carry |
| their true values — they are all known from raw text at inference time. |
| """ |
| from __future__ import annotations |
|
|
| from dataclasses import dataclass |
|
|
| import numpy as np |
| import torch |
|
|
| from meter.backbone import PAD_ID, Stats, normalize_record |
| from meter.marks import IGNORE, SCAN_O, parse_macron_line, parse_scan_line |
|
|
| |
| |
| _SENT_PUNCT = (4, 5) |
|
|
|
|
| @dataclass |
| class Record: |
| chars: np.ndarray |
| boundary: np.ndarray |
| dia: np.ndarray |
| punct: np.ndarray |
| cap: np.ndarray |
| y_mac: np.ndarray |
| y_scan: np.ndarray |
|
|
| def __len__(self): |
| return len(self.chars) |
|
|
|
|
| def _encode_plain(plain: str) -> tuple | None: |
| r = normalize_record(plain, Stats(), with_punct=True) |
| if r is None: |
| return None |
| return r |
|
|
|
|
| def encode_plain(plain: str) -> Record | None: |
| """Unlabeled text (inference input).""" |
| r = _encode_plain(plain) |
| if r is None: |
| return None |
| chars, boundary, dia, cap, punct = r |
| n = len(chars) |
| return Record(chars, boundary, dia, punct, cap, |
| np.full(n, IGNORE, np.int8), np.full(n, IGNORE, np.int8)) |
|
|
|
|
| def _with_labels(plain: str, labels: dict[int, int], task: str) -> Record | None: |
| r = _encode_plain(plain) |
| if r is None: |
| return None |
| chars, boundary, dia, cap, punct = r |
| n = len(chars) |
| if labels and max(labels) >= n: |
| return None |
| y_mac = np.full(n, IGNORE, np.int8) |
| y_scan = np.full(n, IGNORE, np.int8) |
| if task == "mac": |
| for k, v in labels.items(): |
| y_mac[k] = v |
| else: |
| y_scan[:] = SCAN_O |
| for k, v in labels.items(): |
| y_scan[k] = v |
| return Record(chars, boundary, dia, punct, cap, y_mac, y_scan) |
|
|
|
|
| def encode_macron_line(marked: str) -> Record | None: |
| plain, labels = parse_macron_line(marked) |
| if not labels: |
| return None |
| return _with_labels(plain, labels, "mac") |
|
|
|
|
| def encode_scan_line(bracketed: str) -> Record | None: |
| parsed = parse_scan_line(bracketed) |
| if parsed is None: |
| return None |
| plain, labels = parsed |
| return _with_labels(plain, labels, "scan") |
|
|
|
|
| def concat_verses(recs: list[Record]) -> Record: |
| """Join consecutive verse records into one stream record. |
| |
| The codec stamps boundary=2 (sentence end) on every record's last letter; inside |
| a window that would leak verse segmentation to the input, so seams are demoted to |
| word boundaries unless the verse really ends with sentence punctuation. The |
| window-final letter keeps 2 (a record end, exactly as in pretraining packing). |
| """ |
| bnd = [r.boundary.copy() for r in recs] |
| for i, r in enumerate(recs[:-1]): |
| if r.punct[-1] not in _SENT_PUNCT: |
| bnd[i][-1] = 1 |
| return Record(*(np.concatenate(x) for x in ( |
| [r.chars for r in recs], bnd, [r.dia for r in recs], |
| [r.punct for r in recs], [r.cap for r in recs], |
| [r.y_mac for r in recs], [r.y_scan for r in recs]))) |
|
|
|
|
| def make_windows(verses: list[Record], rng: np.random.Generator, |
| passes: int, max_verses: int, T: int) -> list[Record]: |
| """Random runs of 1..max_verses consecutive verses from one work, each pass |
| starting at a fresh offset, every window capped at T letters.""" |
| out = [] |
| nv = len(verses) |
| for _ in range(passes): |
| i = int(rng.integers(0, min(max_verses, nv))) |
| while i < nv: |
| k = int(rng.integers(1, max_verses + 1)) |
| group, total = [], 0 |
| for r in verses[i:i + k]: |
| if total + len(r) > T: |
| break |
| group.append(r) |
| total += len(r) |
| if group: |
| out.append(concat_verses(group) if len(group) > 1 else group[0]) |
| i += len(group) |
| else: |
| i += 1 |
| return out |
|
|
|
|
| |
|
|
| def pack_records(records, T=2048, order=None): |
| """Greedy packing of whole records into rows of <= T letters. Returns |
| (rows, n_skipped) where each row is a list of record indices.""" |
| order = range(len(records)) if order is None else order |
| rows, skipped = [], 0 |
| cur, cur_n = [], 0 |
| for ri in order: |
| r = records[ri] |
| if r is None: |
| continue |
| n = len(r) |
| if n > T: |
| skipped += 1 |
| continue |
| if cur_n + n > T: |
| rows.append(cur) |
| cur, cur_n = [], 0 |
| cur.append(ri) |
| cur_n += n |
| if cur: |
| rows.append(cur) |
| return rows, skipped |
|
|
|
|
| def batch_rows(rows, records, T=2048, device=None, with_slots=False): |
| """Stack rows (lists of record indices) into model + label tensors. |
| |
| Returns input_ids/boundary/dia/punct/cap/seg_id (B,T) plus y_mac/y_scan (B,T); |
| with_slots also returns per-row [(record_index, char_offset)] for mapping |
| per-position predictions back to records. |
| """ |
| B = len(rows) |
| ids = np.full((B, T), PAD_ID, dtype=np.int64) |
| bnd = np.zeros((B, T), dtype=np.int64) |
| dia = np.zeros((B, T), dtype=np.int64) |
| pct = np.zeros((B, T), dtype=np.int64) |
| cp = np.zeros((B, T), dtype=np.int64) |
| seg = np.zeros((B, T), dtype=np.int64) |
| y_m = np.full((B, T), IGNORE, dtype=np.int64) |
| y_s = np.full((B, T), IGNORE, dtype=np.int64) |
| slots = [] |
| for b, row in enumerate(rows): |
| c = 0 |
| rs = [] |
| for k, ri in enumerate(row): |
| r = records[ri] |
| n = len(r) |
| ids[b, c:c + n] = r.chars |
| bnd[b, c:c + n] = r.boundary |
| dia[b, c:c + n] = r.dia |
| pct[b, c:c + n] = r.punct |
| cp[b, c:c + n] = r.cap |
| seg[b, c:c + n] = k + 1 |
| y_m[b, c:c + n] = r.y_mac |
| y_s[b, c:c + n] = r.y_scan |
| rs.append((ri, c)) |
| c += n |
| slots.append(rs) |
| t = lambda a: torch.from_numpy(a) if device is None else torch.from_numpy(a).to(device) |
| out = dict(input_ids=t(ids), boundary=t(bnd), dia=t(dia), punct=t(pct), |
| cap=t(cp), seg_id=t(seg), y_mac=t(y_m), y_scan=t(y_s)) |
| if with_slots: |
| out["slots"] = slots |
| return out |
|
|
|
|
| |
|
|
| _FIELDS = ("chars", "boundary", "dia", "punct", "cap", "y_mac", "y_scan") |
|
|
|
|
| def save_records(path, records, works=None): |
| """Concatenate records into one npz (offsets + planes [+ per-record work names]).""" |
| offsets = np.zeros(len(records) + 1, dtype=np.int64) |
| np.cumsum([len(r) for r in records], out=offsets[1:]) |
| arrays = {f: np.concatenate([getattr(r, f) for r in records]) if records |
| else np.zeros(0, np.int8) for f in _FIELDS} |
| extra = {} |
| if works is not None: |
| extra["works"] = np.array(works) |
| np.savez_compressed(path, offsets=offsets, **arrays, **extra) |
|
|
|
|
| def load_records(path): |
| """-> (records, works|None)""" |
| z = np.load(path, allow_pickle=False) |
| off = z["offsets"] |
| planes = {f: z[f] for f in _FIELDS} |
| records = [Record(**{f: planes[f][off[i]:off[i + 1]] for f in _FIELDS}) |
| for i in range(len(off) - 1)] |
| works = [str(w) for w in z["works"]] if "works" in z else None |
| return records, works |
|
|