File size: 7,530 Bytes
7ed86c3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
"""Stage 7: INDEPENDENT watertightness verification of the materialized folds.

Deliberately does NOT import common.py. Re-implements normalization from
scratch (codepoint table built from unicodedata, not the regex module),
re-tokenizes, and re-hashes (blake2b, not xxhash). For each fold it asserts:

  V1: no word-8-gram of any train record appears in any val/test record
  V2: no full sentence skeleton of any train record equals a val/test sentence
  V3: same two checks between val and test (val must be clean of test)

The spec constants shared with the pipeline (8-gram size, sentence terminator
characters, sigma folding) are re-declared here as documented constants.
"""
import glob
import hashlib
import io
import json
import os
import re
import sys
import unicodedata
from concurrent.futures import ProcessPoolExecutor

import numpy as np
import orjson
import zstandard as zstd

OUTDIR = os.path.expandvars(os.environ.get("FOLD_OUTDIR", "$STOICHEIA_DATA"))
NGRAM = 8  # == pipeline spec (user-approved strictness: shared 8-gram = leak)
SENT_RE = re.compile("[.;!?:\u00b7\u0387\u037e]+|\\n\\s*\\n")
INSCR_REAL = ["edition", "with_diacritics", "without_diacritics", "ithaca_text"]

# ---- independent normalization: one translate table over all codepoints ----
_SIGMA_MAP = {"ς": "σ", "ϲ": "σ", "ϐ": "β", "ϑ": "θ", "ϰ": "κ"}


def _build_table():
    table = {}
    for cp in range(0x30000):
        ch = chr(cp)
        if unicodedata.combining(ch):
            table[cp] = None                      # strip diacritics
            continue
        lo = ch.lower()
        keep = []
        for c in lo:                               # lower() may expand
            c = _SIGMA_MAP.get(c, c)
            # Greek script letters: basic block minus Coptic 03E2-03EF,
            # plus Greek Extended (should not survive NFD, kept for safety)
            o = ord(c)
            is_greek = (0x0370 <= o <= 0x03FF and not 0x03E2 <= o <= 0x03EF) \
                or (0x1F00 <= o <= 0x1FFF)
            if is_greek and unicodedata.category(c).startswith("L"):
                keep.append(c)
            else:
                keep.append(" ")
        table[cp] = "".join(keep)
    return table


TABLE = _build_table()


def norm_words(text):
    return unicodedata.normalize("NFD", text).translate(TABLE).split()


def h(s):
    return int.from_bytes(hashlib.blake2b(s.encode(), digest_size=8).digest(), "big")


def unit_texts(rec):
    if "text" in rec:
        yield rec["text"]
    else:
        for fld in INSCR_REAL:
            v = rec.get(fld)
            if v and str(v).strip():
                yield str(v)


def rec_hashes(rec):
    """(gram_hashes, sentence_hashes) for one output record."""
    grams, sents = [], []
    for text in unit_texts(rec):
        stream = []
        for part in SENT_RE.split(unicodedata.normalize("NFD", text)):
            if part is None or not part:
                continue
            w = norm_words(part)
            if w:
                sents.append(h(" ".join(w)))
                stream.extend(w)
        for i in range(len(stream) - NGRAM + 1):
            grams.append(h(" ".join(stream[i:i + NGRAM])))
    return grams, sents


def read_jsonl_zst(path):
    dctx = zstd.ZstdDecompressor()
    with open(path, "rb") as f:
        with dctx.stream_reader(f) as r:
            for line in io.TextIOWrapper(r, encoding="utf-8"):
                if line.strip():
                    yield orjson.loads(line)


G = {}


def check_batch(lines):
    gk, sk = G["gk"], G["sk"]
    viol = []
    n_g = n_s = 0
    for raw in lines:
        rec = orjson.loads(raw)
        grams, sents = rec_hashes(rec)
        for name, q, keys in (("gram", grams, gk), ("sent", sents, sk)):
            if not q:
                continue
            qa = np.array(q, dtype=np.uint64)
            pos = np.searchsorted(keys, qa)
            pos[pos >= len(keys)] = len(keys) - 1
            nhit = int((keys[pos] == qa).sum())
            if nhit:
                if name == "gram":
                    n_g += nhit
                else:
                    n_s += nhit
                if len(viol) < 5:
                    viol.append({"id": rec.get("id"), "kind": name,
                                 "hits": nhit})
    return n_g, n_s, viol


def build_reference(paths):
    grams, sents = [], []
    for p in paths:
        for rec in read_jsonl_zst(p):
            g, s = rec_hashes(rec)
            grams.extend(g)
            sents.extend(s)
    gk = np.unique(np.array(grams, dtype=np.uint64))
    sk = np.unique(np.array(sents, dtype=np.uint64))
    return gk, sk


def scan(path, workers, batch=4000):
    total_g = total_s = 0
    samples = []
    with ProcessPoolExecutor(max_workers=workers) as ex:
        futs = []
        buf = []
        dctx = zstd.ZstdDecompressor()
        with open(path, "rb") as f:
            with dctx.stream_reader(f) as r:
                for line in io.TextIOWrapper(r, encoding="utf-8"):
                    if line.strip():
                        buf.append(line)
                    if len(buf) >= batch:
                        futs.append(ex.submit(check_batch, buf))
                        buf = []
                        if len(futs) > workers * 4:   # bound memory
                            for fu in futs:
                                g, s, v = fu.result()
                                total_g += g
                                total_s += s
                                samples.extend(v)
                            futs = []
        if buf:
            futs.append(ex.submit(check_batch, buf))
        for fu in futs:
            g, s, v = fu.result()
            total_g += g
            total_s += s
            samples.extend(v)
    return total_g, total_s, samples[:10]


def main():
    folds = [int(x) for x in (sys.argv[1].split(",") if len(sys.argv) > 1
                              else range(10))]
    workers = max(4, os.cpu_count() - 8)
    report = {}
    for k in folds:
        d = os.path.join(OUTDIR, "fold_%d" % k)
        print("fold %d: building val+test reference..." % k, flush=True)
        gk, sk = build_reference([os.path.join(d, "val.jsonl.zst"),
                                  os.path.join(d, "test.jsonl.zst")])
        G["gk"], G["sk"] = gk, sk
        print("  reference: %d grams, %d sentences" % (len(gk), len(sk)),
              flush=True)
        tg, ts, sv = scan(os.path.join(d, "train.jsonl.zst"), workers)
        # val vs test
        gk2, sk2 = build_reference([os.path.join(d, "test.jsonl.zst")])
        G["gk"], G["sk"] = gk2, sk2
        vg, vs, vv = scan(os.path.join(d, "val.jsonl.zst"), workers)
        report["fold_%d" % k] = {
            "train_vs_valtest": {"gram_hits": tg, "sent_hits": ts,
                                 "samples": sv},
            "val_vs_test": {"gram_hits": vg, "sent_hits": vs, "samples": vv},
            "PASS": tg == 0 and ts == 0 and vg == 0 and vs == 0,
        }
        print("fold %d: train-vs-valtest grams=%d sents=%d | val-vs-test "
              "grams=%d sents=%d -> %s" %
              (k, tg, ts, vg, vs,
               "PASS" if report["fold_%d" % k]["PASS"] else "FAIL"), flush=True)
    outp = os.path.join(os.path.dirname(os.path.dirname(
        os.path.abspath(__file__))), "work", "verify_report.json")
    with open(outp, "w") as f:
        json.dump(report, f, indent=2)
    if not all(v["PASS"] for v in report.values()):
        sys.exit(1)


if __name__ == "__main__":
    main()