| """Shared normalization / tokenization / hashing for the 10-fold split pipeline. |
| |
| The matching key ("skeleton") is designed so that differences between editions |
| (diacritics, breathings, iota subscript, sigma forms, editorial brackets, |
| punctuation, casing, line breaks) are invisible to the matcher. |
| """ |
| import unicodedata |
| import regex as re |
| import xxhash |
|
|
| |
| |
| ZONE_B0 = 0 |
| ZONE_PTEST = 10 |
| ZONE_PVAL = 11 |
| ZONE_TRAIN = 12 |
|
|
| N_BUCKETS = 10 |
| NGRAM = 8 |
| |
| |
| MIN_SEG_CHARS = 100 |
| MASK_PTEST = 1 << 10 |
| MASK_PVAL = 1 << 11 |
|
|
|
|
| def fold_conflict_mask(k): |
| """Zones that are val/test in fold k -> bitmask a train sentence must avoid.""" |
| return (1 << k) | (1 << ((k + 1) % N_BUCKETS)) | MASK_PTEST | MASK_PVAL |
|
|
|
|
| |
| _COMBINING = re.compile(r"\p{Mn}+") |
| |
| |
| _NON_GREEK = re.compile(r"[^\p{Script=Greek}]+") |
| _NON_LETTER = re.compile(r"[^\p{L} ]+") |
| _SIGMA = str.maketrans({"ς": "σ", "ϲ": "σ", "Ϲ": "σ", "ϐ": "β", "ϑ": "θ", "ϰ": "κ"}) |
|
|
|
|
| def skeleton(text): |
| """Normalized matching key: diacritic-free lowercase Greek words, space-sep.""" |
| t = unicodedata.normalize("NFD", text) |
| t = _COMBINING.sub("", t) |
| t = t.lower().translate(_SIGMA) |
| t = _NON_GREEK.sub(" ", t) |
| t = _NON_LETTER.sub(" ", t) |
| return " ".join(t.split()) |
|
|
|
|
| |
| |
| |
| |
| _SENT_BOUNDARY = re.compile("[.;!?:\u00b7\u0387\u037e]+|\\n\\s*\\n") |
|
|
|
|
| def sentence_spans(text): |
| """Split into sentence spans [(start, end)) covering the whole string. |
| |
| The terminator and following whitespace belong to the preceding sentence, |
| so concatenating consecutive spans reproduces the original text exactly. |
| Spans whose skeleton is empty are merged into nothing (skipped) but their |
| characters stay attached to the previous span to keep full coverage. |
| """ |
| spans = [] |
| prev = 0 |
| for m in _SENT_BOUNDARY.finditer(text): |
| end = m.end() |
| spans.append((prev, end)) |
| prev = end |
| if prev < len(text): |
| spans.append((prev, len(text))) |
| |
| out = [] |
| for s, e in spans: |
| if skeleton(text[s:e]): |
| out.append([s, e]) |
| elif out: |
| out[-1][1] = e |
| else: |
| out.append([s, e]) |
| |
| if len(out) > 1 and not skeleton(text[out[0][0]:out[0][1]]): |
| out[1][0] = out[0][0] |
| out.pop(0) |
| return [(s, e) for s, e in out] |
|
|
|
|
| |
| def work_prefix(source, rid): |
| """Volume/work granularity used for clustering and sibling-zone lookup. |
| |
| Deliberately NOT source-qualified: the same archive.org volume appears |
| as source greek_pd in pristine and source ia in repaired -- the bare |
| volume/work identifier must match across sources and tiers. |
| """ |
| if source == "oga": |
| return ".".join(rid.split(".")[:2]) |
| return rid.split("#")[0] |
|
|
|
|
| |
| def h64(s): |
| return xxhash.xxh64_intdigest(s) |
|
|
|
|
| def sent_hashes(skel): |
| """(exact_hash, bag_hash, word_list) for one sentence skeleton.""" |
| words = skel.split() |
| exact = h64(skel) |
| bag = h64(" ".join(sorted(words))) |
| return exact, bag, words |
|
|
|
|
| def ngram_hashes(words, n=NGRAM): |
| """Hashes of all word n-grams of a word list (empty if fewer than n words).""" |
| if len(words) < n: |
| return [] |
| return [h64(" ".join(words[i:i + n])) for i in range(len(words) - n + 1)] |
|
|