File size: 20,141 Bytes
c83c617
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
"""V4 tokenizer experiment: shared train-only sample, strict kana checks.

Does not open test or blind text. Existing V1/V2 training stays frozen.
"""
import argparse
import hashlib
import json
import math
import random
import sys
import time
import tomllib
from collections import Counter
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path

from vimeml.tokenizer.monitor import RunMonitor
from vimeml.tokenizer.train import (
    ROOT, SPECIAL_IDS, PROBES, aggregate_measurements, dump_json, example,
    file_sha, measure_range, percentile, train_native, validate,
)

MARKERS = ["<CTX>", "<READ>", "<OUT>"]


def read_json(path):
    return json.loads(path.read_text(encoding="utf-8-sig"))


def permitted_path(path):
    """Reject held-out roles before any file is opened, including symlinks."""
    path = Path(path).resolve()
    if any("blind" in part.lower() or part.lower() in {"test", "test.txt"}
           for part in path.parts):
        raise ValueError(f"Held-out input is forbidden: {path}")
    return path


def sample_train(path, output, size, seed, expected, max_bytes, progress):
    """Explicit reservoir sampling; persist identical bytes for all vocab sizes."""
    rng = random.Random(seed)
    digest = hashlib.sha256()
    rows = []
    count = characters = longest = 0
    with permitted_path(path).open("rb") as stream:
        for count, raw in enumerate(stream, 1):
            digest.update(raw)
            text = raw.decode("utf-8").removesuffix("\n").removesuffix("\r")
            if not text or "▁" in text or any(marker in text for marker in MARKERS):
                raise ValueError(f"Empty/reserved text in train line {count}")
            characters += len(text)
            longest = max(longest, len(text.encode("utf-8")))
            if longest > max_bytes:
                raise ValueError("Train sentence exceeds max_sentence_length; no silent exclusion allowed")
            if len(rows) < size:
                rows.append(raw)
            else:
                index = rng.randrange(count)
                if index < size:
                    rows[index] = raw
            if count % 1_000_000 == 0:
                progress(count, expected["sentences"])
    if count != expected["sentences"] or characters != expected["characters"]:
        raise ValueError("Train counts differ from frozen corpus metadata")
    with output.open("wb") as stream:
        for raw in rows:
            stream.write(raw if raw.endswith(b"\n") else raw + b"\n")
    return {"sentences": count, "characters": characters, "max_utf8_bytes": longest,
            "train_sha256": digest.hexdigest(), "sample_sentences": len(rows),
            "sample_characters": sum(len(raw.decode("utf-8").rstrip("\r\n")) for raw in rows),
            "sample_sha256": file_sha(output), "seed": seed,
            "policy": "Python random.Random(seed) reservoir over every train.txt line; persisted order"}


def verify_model(model, vocab_size, kana):
    import sentencepiece as spm
    from sentencepiece import sentencepiece_model_pb2 as pb
    processor = spm.SentencePieceProcessor(model_file=str(model))
    proto = pb.ModelProto.FromString(model.read_bytes())
    if processor.get_piece_size() != vocab_size:
        raise ValueError("Exact vocabulary size check failed")
    if any(getattr(processor, f"{name}_id")() != value for name, value in SPECIAL_IDS.items()):
        raise ValueError("Special IDs must be PAD/UNK/BOS/EOS = 0/1/2/3")
    normalizer = proto.normalizer_spec
    if (normalizer.name != "identity" or normalizer.add_dummy_prefix
            or normalizer.remove_extra_whitespaces or not normalizer.escape_whitespaces):
        raise ValueError("V4 normalization/whitespace contract failed")
    if (proto.trainer_spec.model_type != pb.TrainerSpec.UNIGRAM
            or not proto.trainer_spec.byte_fallback
            or proto.trainer_spec.required_chars != kana
            or list(proto.trainer_spec.user_defined_symbols) != MARKERS):
        raise ValueError("V4 trainer contract failed")
    for index, marker in enumerate(MARKERS, 4):
        if (processor.piece_to_id(marker) != index
                or proto.pieces[index].type != pb.ModelProto.SentencePiece.USER_DEFINED
                or processor.encode(marker) != [index]
                or processor.decode([index]) != marker):
            raise ValueError(f"Reserved marker contract failed: {marker}")
    checks = []
    for char in kana:
        token = processor.piece_to_id(char)
        ok = (token != processor.unk_id() and processor.id_to_piece(token) == char
              and proto.pieces[token].type == pb.ModelProto.SentencePiece.NORMAL
              and processor.encode(char) == [token] and processor.decode([token]) == char)
        checks.append({"character": char, "codepoint": f"U+{ord(char):04X}", "id": token, "passed": ok})
    if not all(item["passed"] for item in checks):
        raise ValueError(f"Kana single-character check failed: {[c for c in checks if not c['passed']]}")
    byte_count = sum(processor.is_byte(i) for i in range(vocab_size))
    if byte_count != 256:
        raise ValueError("Expected exactly 256 byte tokens")
    probes = [example(processor, text) for text in [*PROBES, *MARKERS, "か\u3099", "ガ", "ヴヷヸヹヺ"]]
    if any(not p["roundtrip_ok"] or processor.unk_id() in p["ids"] for p in probes):
        raise ValueError(f"Probe UNK/roundtrip check failed: {[p for p in probes if not p['roundtrip_ok'] or processor.unk_id() in p['ids']]}")
    return {"status": "passed", "vocab_size": vocab_size, "special_ids": SPECIAL_IDS,
            "marker_ids": dict(zip(MARKERS, range(4, 7))), "byte_piece_count": byte_count,
            "normal_piece_count": vocab_size - 263, "required_kana_count": len(kana),
            "kana_checks": checks, "probes": probes}


def measure_corpora(model, paths, expected, workers, batch_size, progress):
    """Exact statistics on the training sample and full validation, without test."""
    import sentencepiece as spm
    jobs = []
    collected = {split: [] for split in paths}
    for split, path in paths.items():
        path = permitted_path(path)
        size = path.stat().st_size
        partitions = min(workers * 4, max(1, math.ceil(size / (32 * 1024 * 1024))))
        for index in range(partitions):
            jobs.append((model, path, size * index // partitions,
                         size * (index + 1) // partitions, split, batch_size))
    completed = 0
    total = sum(item["sentences"] for item in expected.values())
    started = time.monotonic()
    with ProcessPoolExecutor(max_workers=workers) as pool:
        pending = {pool.submit(measure_range, job): job[4] for job in jobs}
        for future in as_completed(pending):
            split = pending[future]
            result = future.result()
            collected[split].append(result)
            completed += result["sentences"]
            progress(completed, total, time.monotonic() - started)
    processor = spm.SentencePieceProcessor(model_file=str(model))
    stats = {}
    for split, results in collected.items():
        stats[split], _ = aggregate_measurements(processor, results, split)
        if any(stats[split][key] != expected[split][key] for key in ("sentences", "characters")):
            raise ValueError(f"Measurement count mismatch: {split}")
        if stats[split]["unknown_tokens"] or stats[split]["roundtrip_mismatches"]:
            raise ValueError(f"Measurement UNK/roundtrip failure: {split}")
        stats[split]["estimated_characters_in_128_content_tokens"] = 128 * stats[split]["characters_per_token"]
        stats[split]["estimated_characters_in_126_content_tokens_plus_bos_eos"] = 126 * stats[split]["characters_per_token"]
    return stats


def text_statistics(processor, texts):
    lengths = Counter()
    characters = tokens = byte_tokens = unknown = mismatches = 0
    coverage = []
    for text in texts:
        ids = processor.encode(text)
        lengths[len(ids)] += 1
        characters += len(text)
        tokens += len(ids)
        byte_tokens += sum(processor.is_byte(i) for i in ids)
        unknown += ids.count(processor.unk_id())
        mismatches += processor.decode(ids) != text
        # Immutable proto offsets use Unicode character positions in Python.
        proto = processor.encode(text, out_type="immutable_proto")
        if proto.pieces:
            coverage.append(proto.pieces[min(126, len(proto.pieces)) - 1].end)
        else:
            coverage.append(0)
    count = len(texts)
    if unknown or mismatches:
        raise ValueError("Development text failed UNK/roundtrip verification")
    return {"texts": count, "characters": characters, "tokens": tokens,
            "mean_tokens": tokens / count if count else None,
            "characters_per_token": characters / tokens if tokens else None,
            "byte_fallback_tokens": byte_tokens,
            "byte_fallback_token_fraction": byte_tokens / tokens if tokens else None,
            "unknown_tokens": unknown, "roundtrip_mismatches": mismatches,
            "token_length_percentiles": {f"p{n}": percentile(lengths, n / 100) for n in (50, 95, 99)},
            "fits_128_with_bos_eos_fraction": sum(n for length, n in lengths.items() if length + 2 <= 128) / count if count else None,
            "mean_characters_retained_in_128_with_bos_eos": sum(coverage) / count if count else None}


def measure_development(model, directory):
    """Read the explicit development directory only; no LM or ranking scores."""
    import sentencepiece as spm
    directory = permitted_path(directory)
    manifest_path = directory / "manifest.json"
    manifest = read_json(manifest_path)
    if (directory.name != "development" or manifest.get("split") != "development"
            or manifest.get("status") != "complete"
            or manifest.get("format") != "vimeml_standard_ime_candidates_v1"):
        raise ValueError("Only completed standard development candidates are permitted")
    from vimeml.benchmarks.standard_ime import load_standard_export
    _, _, rows = load_standard_export(directory)
    if not rows or any(row.get("provenance", {}).get("role") != "development" for row in rows):
        raise ValueError("Benchmark contains a non-development row")
    processor = spm.SentencePieceProcessor(model_file=str(model))
    from vimeml.benchmarks.standard_ime import read
    originals_path = directory / "evaluation_items.json"
    originals = read(originals_path)
    candidates = [candidate["text"] for row in rows for candidate in row["candidates"]]
    readings = [row["input"] for row in originals]
    # Katakana -> hiragana for later reading tasks; originals remain immutable.
    hiragana = ["".join(chr(ord(c) - 0x60) if "ァ" <= c <= "ヶ" else c for c in text) for text in readings]
    result = {"role": "development", "release_id": manifest["release_id"], "cases": len(rows),
              "purpose": "Tokenizer statistics only; no probability/ranking/reference accuracy",
              "candidates": text_statistics(processor, candidates),
              "reading_original_katakana": text_statistics(processor, readings),
              "reading_hiragana_diagnostic": text_statistics(processor, hiragana),
              "context_plus_candidate": text_statistics(processor, [row["left_context"] + c["text"] for row in rows for c in row["candidates"]])}
    result["input_sha256"] = {str(path): file_sha(path) for path in (
        manifest_path, originals_path, directory / "ajimee-results/azookey-candidates.json")}
    return result


def run(config_path, dry_run=False):
    import sentencepiece as spm
    if spm.__version__ != "0.2.1":
        raise ValueError("V4 requires sentencepiece==0.2.1")
    config_path = permitted_path(config_path)
    config = tomllib.loads(config_path.read_text(encoding="utf-8-sig"))
    settings = config["training"]
    validate(settings)
    if (settings["model_type"] != "unigram" or settings["vocab_size"] != 8192
            or settings.get("add_dummy_prefix") is not False
            or settings.get("user_defined_symbols") != MARKERS
            or settings["input_sentence_size"] <= 100):
        raise ValueError("Fixed V4-a tokenizer contract differs from configuration")
    sizes = config["comparison"]["vocab_sizes"]
    if sizes != [8192, 12288, 16384]:
        raise ValueError("Expected 8K/12K/16K comparison")
    measurement = config["measurement"]
    if any(type(measurement[k]) is not int or measurement[k] < 1 for k in ("workers", "batch_size")):
        raise ValueError("Measurement resources must be positive integers")
    paths = config["paths"]
    corpus = permitted_path(ROOT / paths["corpus_dir"])
    output = permitted_path(ROOT / paths["output_dir"])
    kana_path = permitted_path(ROOT / paths["required_chars_file"])
    kana = "".join(kana_path.read_text(encoding="utf-8").splitlines())
    if not kana or len(set(kana)) != len(kana):
        raise ValueError("Kana list must be nonempty and unique")
    benchmark = permitted_path(ROOT / paths["benchmark_development"])
    if benchmark.name != "development":
        raise ValueError("Benchmark path must point specifically to development")
    if output.exists() and any(output.iterdir()):
        raise FileExistsError(f"Refusing to overwrite existing experiment: {output}")
    corpus_manifest = read_json(corpus / "manifest.json")
    stats = read_json(corpus / "stats.json")["splits"]
    integrity = read_json(corpus / "integrity-check.json")
    if (corpus_manifest.get("status") != "complete" or integrity.get("status") != "passed"
            or file_sha(corpus / "manifest.json") != integrity["manifest_sha256"]):
        raise ValueError("Frozen corpus integrity metadata failed")
    inputs = [config_path, kana_path, corpus / "train.txt", corpus / "validation.txt",
              corpus / "manifest.json", corpus / "stats.json", corpus / "integrity-check.json"]
    before = {str(p): {"bytes": p.stat().st_size, "mtime_ns": p.stat().st_mtime_ns} for p in inputs}
    plan = {"output": str(output), "vocab_sizes": sizes, "required_kana_count": len(kana),
            "trained_on": "train.txt only; one persisted reservoir sample",
            "sample_size": settings["input_sentence_size"], "seed": settings["seed"],
            "evaluated_on": ["training sample", "full validation", "standard development"],
            "test_and_blind_access": False, "lm_training_started": False,
            "dry_run": dry_run}
    print(json.dumps(plan, ensure_ascii=False, indent=2), flush=True)
    if dry_run:
        return plan
    output.mkdir(parents=True)
    dump_json(output / "config.json", config)
    dump_json(output / "plan.json", plan)
    sample_path = output / "train-sample.txt"
    started = time.monotonic()
    with RunMonitor(output, ROOT / "runs/tokenizer/v4-a-sampling", config["monitoring"]["tensorboard"]) as monitor:
        monitor.update("checking_inputs")
        sample = sample_train(corpus / "train.txt", sample_path, settings["input_sentence_size"],
                              settings["seed"], stats["train"], settings["max_sentence_length"],
                              lambda done, total: monitor.update("checking_inputs", preflight_completed_sentences=done, preflight_total_sentences=total))
        if sample["train_sha256"] != integrity["exports"]["train.txt"]["sha256"]:
            raise ValueError("Actual train SHA256 differs from frozen corpus")
        validation_hash = file_sha(corpus / "validation.txt")
        if validation_hash != integrity["exports"]["validation.txt"]["sha256"]:
            raise ValueError("Actual validation SHA256 differs from frozen corpus")
        dump_json(output / "sampling.json", sample)
        monitor.update("complete")
    results = {}
    for size in sizes:
        target = output / f"ja-unigram-{size}"
        target.mkdir()
        with RunMonitor(target, ROOT / "runs/tokenizer" / f"v4-a-{size}", config["monitoring"]["tensorboard"]) as monitor:
            monitor.update("training")
            options = {k: v for k, v in settings.items() if k not in {"seed", "input_sentence_size"}}
            options.update(input=str(sample_path), model_prefix=str(target / "tokenizer"), vocab_size=size,
                           input_sentence_size=0, shuffle_input_sentence=False, hard_vocab_limit=True,
                           escape_whitespaces=True, required_chars=kana,
                           **{f"{name}_id": value for name, value in SPECIAL_IDS.items()})
            train_native(options, settings["seed"], target, monitor)
            model = target / "tokenizer.model"
            verification = verify_model(model, size, kana)
            dump_json(target / "validation.json", verification)
            monitor.update("measuring")
            split_stats = measure_corpora(model, {"train_sample": sample_path, "validation": corpus / "validation.txt"},
                {"train_sample": {"sentences": sample["sample_sentences"], "characters": sample["sample_characters"]}, "validation": stats["validation"]},
                measurement["workers"], measurement["batch_size"],
                lambda done, total, elapsed: monitor.update("measuring", completed_sentences=done, total_sentences=total, measurement_elapsed_seconds=elapsed))
            development = measure_development(model, benchmark)
            result = {"vocab_size": size, "normal_piece_count": size - 263,
                      "splits": split_stats, "development": development}
            dump_json(target / "stats.json", result)
            dump_json(target / "manifest.json", {"status": "complete", "trained_on": "shared train-only sample",
                "model_sha256": file_sha(model), "vocab_sha256": file_sha(target / "tokenizer.vocab"),
                "sample_sha256": sample["sample_sha256"], "trainer_options": options,
                "sentencepiece_version": spm.__version__, "special_ids": SPECIAL_IDS,
                "marker_ids": verification["marker_ids"], "test_and_blind_access": False})
            monitor.final_statistics(split_stats)
            monitor.update("complete")
            results[str(size)] = result
            print(f"[complete] vocab={size}; validation chars/token={split_stats['validation']['characters_per_token']:.4f}", flush=True)
    after = {str(p): {"bytes": p.stat().st_size, "mtime_ns": p.stat().st_mtime_ns} for p in inputs}
    if before != after or file_sha(sample_path) != sample["sample_sha256"]:
        raise ValueError("Inputs changed during training")
    dump_json(output / "comparison.json", results)
    dump_json(output / "manifest.json", {"status": "complete", "primary_vocab_size": 8192,
        "primary_model": str(output / "ja-unigram-8192/tokenizer.model"), "plan": plan,
        "sample": sample, "validation_sha256": validation_hash, "input_metadata": before,
        "input_sha256": {str(p): file_sha(p) for p in inputs if p.name not in {"train.txt", "validation.txt"}},
        "code_sha256": {str(p): file_sha(p) for p in [Path(__file__), Path(__file__).with_name("train.py"), Path(__file__).with_name("native_train.py"), Path(__file__).with_name("monitor.py")]},
        "python_version": sys.version, "elapsed_seconds": time.monotonic() - started,
        "corpus_ready_for_lm_training": corpus_manifest.get("ready_for_lm_training", False),
        "reproducibility": "Exact shared sample and artifacts are preserved; native multithreading/platform can affect learned IDs",
        "context_coverage_note": "Corpus 128-token character estimates are ratio-based, not measured packed-window coverage. Development prefix coverage is measured and includes short texts."})
    print(f"[complete] V4 tokenizer experiment: {output}", flush=True)
    return results


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--config", type=Path, default=ROOT / "configs/tokenizer-v4.toml")
    parser.add_argument("--dry-run", action="store_true")
    args = parser.parse_args()
    run(args.config, args.dry_run)


if __name__ == "__main__":
    main()