Download source/src/vimeml/tokenizer/encode_v4.py from Voltline/vimeml-tiny-ja-v4.2: direct link, hf CLI and curl.
- Browser
- Download file 23.7 kB
-
https://huggingface.co/Voltline/vimeml-tiny-ja-v4.2/resolve/main/source/src/vimeml/tokenizer/encode_v4.py
- Command line
-
hf download hf://Voltline/vimeml-tiny-ja-v4.2/source/src/vimeml/tokenizer/encode_v4.py
-
curl -L -o encode_v4.py https://huggingface.co/Voltline/vimeml-tiny-ja-v4.2/resolve/main/source/src/vimeml/tokenizer/encode_v4.py
23.7 kB
| """V4-only TXT encoding with provenance, strict checks and resumable parts. | |
| Original training JSONL was pruned locally. Verified V2 source IDs are reused; | |
| all token sequences and row byte positions are regenerated from the frozen TXT. | |
| No test/blind text is read, and V1/V2 implementations remain unchanged. | |
| """ | |
| import argparse | |
| import hashlib | |
| import json | |
| import time | |
| import tomllib | |
| from collections import Counter | |
| from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed | |
| from pathlib import Path | |
| from vimeml.tokenizer.encode import dump_json, file_sha, write_numbers | |
| from vimeml.tokenizer.encode_parallel import cached_part, output_lock, part_paths | |
| from vimeml.tokenizer.monitor import RunMonitor | |
| from vimeml.tokenizer.v4 import MARKERS, ROOT, permitted_path, read_json, verify_model | |
| SPLITS = ("train", "validation") | |
| FORMAT = "vimeml_sentence_tokens_v1" | |
| def recorded_hash(manifest, name, parent=None): | |
| values = [v for k, v in manifest["input_sha256"].items() | |
| if k.replace("\\", "/").rsplit("/", 1)[-1] == name | |
| and (parent is None or k.replace("\\", "/").endswith(f"/{parent}/{name}"))] | |
| if len(values) != 1: | |
| raise ValueError(f"Missing unique recorded corpus hash: {name}") | |
| return values[0] | |
| def scan_txt(path, split, chunk_bytes, expected, progress): | |
| """Hash and partition at exact line boundaries, retaining source row indices.""" | |
| path = permitted_path(path) | |
| digest = hashlib.sha256() | |
| jobs = [] | |
| count = characters = start = first_row = end = longest = 0 | |
| with path.open("rb") as stream: | |
| for raw in stream: | |
| 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"Invalid/reserved text: {split} row {count}") | |
| count += 1 | |
| characters += len(text) | |
| longest = max(longest, len(text.encode("utf-8"))) | |
| end = stream.tell() | |
| if end - start >= chunk_bytes: | |
| jobs.append({"split": split, "input": str(path), "start": start, "end": end, | |
| "first_row": first_row, "sentences": count - first_row}) | |
| start, first_row = end, count | |
| if count % 1_000_000 == 0: | |
| progress(count, expected["sentences"]) | |
| if end > start: | |
| jobs.append({"split": split, "input": str(path), "start": start, "end": end, | |
| "first_row": first_row, "sentences": count - first_row}) | |
| if count != expected["sentences"] or characters != expected["characters"]: | |
| raise ValueError(f"Corpus count mismatch: {split}") | |
| return jobs, {"sentences": count, "characters": characters, "max_utf8_bytes": longest, | |
| "sha256": digest.hexdigest()} | |
| def encode_txt_part(job): | |
| import sentencepiece as spm | |
| processor = spm.SentencePieceProcessor(model_file=job["model"]) | |
| files = part_paths(job) | |
| files["done.json"].unlink(missing_ok=True) | |
| reserved = set(range(7)) | |
| byte_ids = {i for i in range(8192) if processor.is_byte(i)} | |
| reverse_sources = {value: name for name, value in job["source_ids"].items()} | |
| count = content = stored = characters = byte_tokens = longest = 0 | |
| lengths, sources = Counter(), Counter() | |
| text_digest, source_digest = hashlib.sha256(), hashlib.sha256() | |
| with Path(job["input"]).open("rb") as text_stream, Path(job["source_input"]).open("rb") as source_stream, \ | |
| files["tokens.bin"].open("wb") as token_stream, files["offsets.bin"].open("wb") as offset_stream, \ | |
| files["rows.bin"].open("wb") as row_stream, files["sources.bin"].open("wb") as out_sources: | |
| text_stream.seek(job["start"]) | |
| source_stream.seek(job["first_row"]) | |
| write_numbers(offset_stream, "Q", [0]) | |
| while text_stream.tell() < job["end"]: | |
| texts, positions = [], [] | |
| for _ in range(job["batch_size"]): | |
| position = text_stream.tell() | |
| if position >= job["end"]: | |
| break | |
| raw = text_stream.readline() | |
| if not raw: | |
| raise ValueError("Unexpected TXT end") | |
| text_digest.update(raw) | |
| text = raw.decode("utf-8").removesuffix("\n").removesuffix("\r") | |
| if not text: | |
| raise ValueError("Empty sentence") | |
| texts.append(text) | |
| positions.append(position) | |
| encoded = processor.encode(texts, out_type=int, num_threads=1) | |
| decoded = processor.decode(encoded, num_threads=1) | |
| source_bytes = source_stream.read(len(texts)) | |
| if len(source_bytes) != len(texts): | |
| raise ValueError("Source index is shorter than corpus") | |
| source_digest.update(source_bytes) | |
| batch_tokens, batch_offsets = [], [] | |
| for text, ids, restored, source in zip(texts, encoded, decoded, source_bytes, strict=True): | |
| if restored != text or any(i in reserved or i >= 8192 for i in ids): | |
| raise ValueError(f"Roundtrip/reserved/ID error: {job['name']} row {count}") | |
| if source not in reverse_sources: | |
| raise ValueError("Unknown source ID") | |
| count += 1 | |
| characters += len(text) | |
| content += len(ids) | |
| stored += len(ids) + 2 | |
| longest = max(longest, len(ids) + 2) | |
| lengths[len(ids)] += 1 | |
| byte_tokens += sum(i in byte_ids for i in ids) | |
| sources[reverse_sources[source]] += 1 | |
| batch_tokens.extend((2, *ids, 3)) | |
| batch_offsets.append(stored) | |
| write_numbers(token_stream, "H", batch_tokens) | |
| write_numbers(offset_stream, "Q", batch_offsets) | |
| write_numbers(row_stream, "Q", positions) | |
| out_sources.write(source_bytes) | |
| if text_stream.tell() != job["end"] or count != job["sentences"]: | |
| raise ValueError("TXT partition boundaries/counts changed") | |
| sizes = {suffix: path.stat().st_size for suffix, path in files.items() if suffix != "done.json"} | |
| if sizes != {"tokens.bin": stored * 2, "offsets.bin": (count + 1) * 8, | |
| "rows.bin": count * 8, "sources.bin": count}: | |
| raise ValueError("Part file sizes are inconsistent") | |
| result = {"name": job["name"], "signature": job["signature"], "split": job["split"], | |
| "start": job["start"], "end": job["end"], "first_row": job["first_row"], | |
| "sentences": count, "characters": characters, "stored_tokens": stored, | |
| "content_tokens": content, "max_sequence_tokens": longest, | |
| "primary_sources": dict(sources), "lengths": dict(lengths), | |
| "byte_fallback_tokens": byte_tokens, "unknown_tokens": 0, "roundtrip_mismatches": 0, | |
| "txt_part_sha256": text_digest.hexdigest(), "source_part_sha256": source_digest.hexdigest(), | |
| "files": {suffix: {"bytes": path.stat().st_size, "sha256": file_sha(path)} | |
| for suffix, path in files.items() if suffix != "done.json"}} | |
| dump_json(files["done.json"], result) | |
| return result | |
| def collect(jobs, workers, monitor): | |
| results, pending = {}, [] | |
| for job in jobs: | |
| cached = cached_part(job) | |
| if cached: | |
| # JSON object histogram keys need converting back to lengths. | |
| cached["lengths"] = {int(k): v for k, v in cached["lengths"].items()} | |
| results[job["name"]] = cached | |
| else: | |
| pending.append(job) | |
| total = sum(job["sentences"] for job in jobs) | |
| done = sum(r["sentences"] for r in results.values()) | |
| initial, started = done, time.monotonic() | |
| print(f"[encode] cached={len(results)} pending={len(pending)} total_rows={total:,}", flush=True) | |
| with ProcessPoolExecutor(max_workers=workers) as pool: | |
| futures = {pool.submit(encode_txt_part, job): job for job in pending} | |
| for future in as_completed(futures): | |
| job = futures[future] | |
| result = future.result() | |
| results[job["name"]] = result | |
| done += result["sentences"] | |
| elapsed = time.monotonic() - started | |
| speed = (done - initial) / elapsed | |
| monitor.update("encoding", completed_sentences=done, total_sentences=total, | |
| encoding_sentences_per_second=speed, | |
| encoding_eta_seconds=(total - done) / speed) | |
| print(f"[encode] {done:,}/{total:,} ({100*done/total:.1f}%) {speed:,.0f} rows/s", flush=True) | |
| return results | |
| def merge_txt_split(output, split, jobs, results, expected, monitor): | |
| from vimeml.tokenizer.encode_parallel import merge_split | |
| totals, histogram, sources = Counter(), Counter(), Counter() | |
| for job in jobs: | |
| result = results[job["name"]] | |
| for key in ("sentences", "characters", "content_tokens", "stored_tokens", "byte_fallback_tokens"): | |
| totals[key] += result[key] | |
| histogram.update(result["lengths"]) | |
| sources.update(result["primary_sources"]) | |
| if (totals["sentences"] != expected["sentences"] or totals["characters"] != expected["characters"] | |
| or dict(sources) != expected["primary_sources"]): | |
| raise ValueError(f"Full split counts/source counts changed: {split}") | |
| # This helper checks all offsets and merged sizes. Its JSONL-specific TXT | |
| # alignment branch is bypassed because these parts were encoded from TXT. | |
| exported = merge_split(None, output, split, jobs, results, | |
| {**dict(totals), "tokens_without_special_tokens": totals["content_tokens"], | |
| "tokens_with_bos_eos_per_sentence": totals["stored_tokens"]}, monitor, 0, | |
| totals["sentences"], verification="metadata") | |
| from vimeml.tokenizer.train import percentile | |
| exported.update(unknown_tokens=0, roundtrip_mismatches=0, byte_fallback_tokens=totals["byte_fallback_tokens"], | |
| byte_fallback_token_fraction=totals["byte_fallback_tokens"] / totals["content_tokens"], | |
| characters_per_token=totals["characters"] / totals["content_tokens"], | |
| mean_tokens_per_sentence=totals["content_tokens"] / totals["sentences"], | |
| length_percentiles_without_special_tokens={f"p{n}": percentile(histogram, n / 100) for n in (50, 95, 99)}, | |
| fits_context_with_bos_eos={"128": {"sentences": sum(n for length, n in histogram.items() if length + 2 <= 128), | |
| "fraction": sum(n for length, n in histogram.items() if length + 2 <= 128) / totals["sentences"]}}) | |
| return exported | |
| def prepare_v4_indexes(token_dir, index_dir, checks): | |
| from vimeml.training.data import SentenceWindowDataset, prepare_split | |
| from vimeml.training.check_data import check_core, check_torch | |
| token_sha = file_sha(token_dir / "manifest.json") | |
| if (index_dir / "manifest.json").exists(): | |
| existing = read_json(index_dir / "manifest.json") | |
| if existing.get("token_manifest_sha256") != token_sha or existing.get("context_length") != 128: | |
| raise ValueError("Index directory belongs to another token dataset") | |
| index_dir.mkdir(parents=True, exist_ok=True) | |
| splits = {} | |
| with ThreadPoolExecutor(max_workers=2) as pool: | |
| pending = {split: pool.submit(prepare_split, token_dir, index_dir, split, 128, token_sha, "sha256") for split in SPLITS} | |
| for split, future in pending.items(): | |
| splits[split] = future.result() | |
| index = {"status": "complete", "format": "vimeml_sentence_windows_v1", "context_length": 128, | |
| "token_manifest_sha256": token_sha, "token_file_verification": "sha256", | |
| "available_splits": list(SPLITS), "splits": splits, | |
| "policy": "Separate sentences; nonoverlapping target windows with shared boundary input; all prediction pairs retained."} | |
| dump_json(index_dir / "manifest.json", index) | |
| report = {"status": "passed", "core_checks": {}, "pytorch_checks": {}, "config": checks} | |
| for split in SPLITS: | |
| report["core_checks"][split] = check_core(SentenceWindowDataset(token_dir, index_dir, split), checks["seed"]) | |
| report["pytorch_checks"][split] = check_torch(SentenceWindowDataset(token_dir, index_dir, split), checks) | |
| dump_json(index_dir / "loader-check.json", report) | |
| return index, report | |
| def run(config_path, resume=False, dry_run=False): | |
| import sentencepiece as spm | |
| config_path = permitted_path(config_path) | |
| config = tomllib.loads(config_path.read_text(encoding="utf-8")) | |
| paths = {key: permitted_path(ROOT / value) for key, value in config["paths"].items()} | |
| corpus, tokenizer, source_store = (paths[k] for k in ("corpus_dir", "tokenizer_dir", "source_store_dir")) | |
| output, index_dir = paths["output_dir"], paths["index_dir"] | |
| encoding, checks = config["encoding"], config["checks"] | |
| if config["windows"]["context_length"] != 128: | |
| raise ValueError("V4 context is fixed to 128") | |
| for key in ("workers", "batch_size", "chunk_mib"): | |
| if type(encoding[key]) is not int or encoding[key] < 1: | |
| raise ValueError(f"Invalid encoding resource: {key}") | |
| cm, cs, integrity = (read_json(corpus / name) for name in ("manifest.json", "stats.json", "integrity-check.json")) | |
| tm, ts = (read_json(tokenizer / name) for name in ("manifest.json", "stats.json")) | |
| experiment = read_json(paths["tokenizer_experiment_dir"] / "manifest.json") | |
| sm = read_json(source_store / "manifest.json") | |
| if any(m.get("status") != "complete" for m in (cm, tm, experiment, sm)) or integrity.get("status") != "passed": | |
| raise ValueError("Complete frozen input manifests required") | |
| model = tokenizer / "tokenizer.model" | |
| if spm.__version__ != tm["sentencepiece_version"] or file_sha(model) != tm["model_sha256"]: | |
| raise ValueError("Tokenizer hash/version changed") | |
| if Path(experiment["primary_model"]).resolve() != model.resolve(): | |
| raise ValueError("Use the selected V4 8K model") | |
| kana = tm["trainer_options"]["required_chars"] | |
| verify_model(model, 8192, kana) | |
| corpus_sha = file_sha(corpus / "manifest.json") | |
| if corpus_sha != integrity["manifest_sha256"] or corpus_sha != recorded_hash(sm, "manifest.json", corpus.name): | |
| raise ValueError("Source provenance belongs to another corpus") | |
| expected_hashes = {"train": experiment["sample"]["train_sha256"], "validation": experiment["validation_sha256"]} | |
| for split in SPLITS: | |
| if (expected_hashes[split] != integrity["exports"][f"{split}.txt"]["sha256"] | |
| or expected_hashes[split] != recorded_hash(sm, f"{split}.txt") | |
| or any(sm["splits"][split][key] != cs["splits"][split][key] for key in ("sentences", "characters", "primary_sources"))): | |
| raise ValueError("Source and tokenizer corpus identity mismatch") | |
| plan = {"vocab_size": 8192, "context_length": 128, "available_splits": list(SPLITS), | |
| "sentences": {s: cs["splits"][s]["sentences"] for s in SPLITS}, | |
| "paths": {k: str(v) for k, v in paths.items()}, "encoding": encoding, | |
| "test_and_blind_access": False, "model_training_started": False, "dry_run": dry_run} | |
| print(json.dumps(plan, ensure_ascii=False, indent=2), flush=True) | |
| if dry_run: | |
| return plan | |
| if output.exists() and any(output.iterdir()) and not resume: | |
| raise FileExistsError("Output is nonempty; choose a new path or --resume") | |
| if resume and not output.exists(): | |
| raise FileNotFoundError("Cannot resume an absent token store") | |
| output.mkdir(parents=True, exist_ok=True) | |
| with output_lock(output): | |
| if (output / "manifest.json").exists(): | |
| complete = read_json(output / "manifest.json") | |
| if complete.get("status") == "complete": | |
| if complete["tokenizer_model_sha256"] != tm["model_sha256"]: | |
| raise ValueError("Completed output belongs to another tokenizer") | |
| for name, digest in complete["output_sha256"].items(): | |
| if file_sha(output / name) != digest: | |
| raise ValueError(f"Completed output checksum mismatch: {name}") | |
| return prepare_v4_indexes(output, index_dir, checks) | |
| started = time.monotonic() | |
| with RunMonitor(output, ROOT / "runs/tokenizer/corpus-v4-8k-encode" / str(time.time_ns()), True) as monitor: | |
| monitor.update("checking_inputs") | |
| inputs = [config_path, model, tokenizer / "manifest.json", tokenizer / "stats.json", | |
| paths["tokenizer_experiment_dir"] / "manifest.json", source_store / "manifest.json", | |
| corpus / "manifest.json", corpus / "stats.json", corpus / "integrity-check.json"] | |
| inputs += [source_store / f"{split}.sources.bin" for split in SPLITS] | |
| inputs += [Path(__file__), Path(__file__).with_name("encode_parallel.py"), Path(__file__).with_name("encode.py"), | |
| Path(__file__).with_name("monitor.py"), Path(__file__).with_name("v4.py"), | |
| ROOT / "src/vimeml/training/data.py", ROOT / "src/vimeml/training/check_data.py"] | |
| inputs += [corpus / f"{split}.txt" for split in SPLITS] | |
| metadata = {str(p): {"bytes": p.stat().st_size, "mtime_ns": p.stat().st_mtime_ns} for p in inputs} | |
| hashes = {str(p): file_sha(p) for p in inputs if p.suffix != ".txt"} | |
| jobs, scans = [], {} | |
| for split in SPLITS: | |
| source_path = source_store / f"{split}.sources.bin" | |
| if (source_path.stat().st_size != cs["splits"][split]["sentences"] | |
| or hashes[str(source_path)] != sm["output_sha256"][source_path.name]): | |
| raise ValueError("Frozen source index changed") | |
| selected, scans[split] = scan_txt(corpus / f"{split}.txt", split, | |
| encoding["chunk_mib"] * 1024 * 1024, cs["splits"][split], | |
| lambda done, total: monitor.update("checking_inputs", preflight_completed_sentences=done, preflight_total_sentences=total)) | |
| hashes[str(corpus / f"{split}.txt")] = scans[split]["sha256"] | |
| if scans[split]["sha256"] != expected_hashes[split]: | |
| raise ValueError(f"Actual TXT checksum changed: {split}") | |
| for i, job in enumerate(selected): | |
| job.update(name=f"{split}-{i:05d}", source_input=str(source_path)) | |
| jobs.extend(selected) | |
| signature = hashlib.sha256(json.dumps({"hashes": hashes, "encoding": encoding, | |
| "splits": list(SPLITS)}, sort_keys=True).encode()).hexdigest() | |
| parts = output / ".parts" | |
| parts.mkdir(exist_ok=True) | |
| for job in jobs: | |
| job.update(model=str(model), parts=str(parts), batch_size=encoding["batch_size"], | |
| signature=signature, source_ids=sm["source_ids"], verification="sha256") | |
| dump_json(output / "encoding-plan.json", {"signature": signature, "plan": plan, "scans": scans}) | |
| results = collect(jobs, encoding["workers"], monitor) | |
| exported = {} | |
| for split in SPLITS: | |
| selected = [job for job in jobs if job["split"] == split] | |
| exported[split] = merge_txt_split(output, split, selected, results, cs["splits"][split], monitor) | |
| # Full validation statistics must match tokenizer-stage measurements. | |
| for key, other in (("content_tokens", "tokens_without_special_tokens"), ("stored_tokens", "tokens_with_bos_eos_per_sentence"), | |
| ("byte_fallback_tokens", "byte_fallback_tokens")): | |
| if exported["validation"][key] != ts["splits"]["validation"][other]: | |
| raise ValueError("Full validation token counts changed since tokenizer acceptance") | |
| monitor.update("verifying_inputs") | |
| if any(file_sha(p) != hashes[str(p)] for p in inputs): | |
| raise ValueError("Inputs changed during encoding") | |
| dump_json(output / "stats.json", {"actual_vocab_size": 8192, "splits": exported}) | |
| artifacts = [output / f"{s}.{suffix}" for s in SPLITS for suffix in ("tokens.bin", "offsets.bin", "rows.bin", "sources.bin")] | |
| artifacts.append(output / "stats.json") | |
| output_hashes = {p.name: file_sha(p) for p in artifacts} | |
| for split in SPLITS: | |
| if output_hashes[f"{split}.sources.bin"] != sm["output_sha256"][f"{split}.sources.bin"]: | |
| raise ValueError("Source rows were reordered during encoding") | |
| dump_json(output / "part-audit.json", {"signature": signature, "parts": [results[job["name"]] for job in jobs]}) | |
| dump_json(output / "manifest.json", {"status": "complete", "format": FORMAT, "vocab_size": 8192, | |
| "token_dtype": "uint16_le", "offset_dtype": "uint64_le", "offset_unit": "tokens", | |
| "special_ids": tm["special_ids"], "reserved_marker_ids": tm["marker_ids"], | |
| "sentencepiece_version": spm.__version__, "tokenizer_model_sha256": tm["model_sha256"], | |
| "tokenizer_dir": str(tokenizer), "corpus_dir": str(corpus), "available_splits": list(SPLITS), | |
| "splits": exported, "source_ids": sm["source_ids"], "input_sha256": hashes, | |
| "output_sha256": output_hashes, "input_metadata": metadata, "signature": signature, | |
| "sequence_policy": "BOS + complete sentence + EOS; no truncation, concatenation or task markers.", | |
| "row_offset_source": "corpus_txt", "row_offset_dtype": "uint64_le", | |
| "provenance_policy": "rows.bin stores uint64 byte offsets into the corresponding frozen TXT, not the removed JSONL. sources.bin preserves verified original primary source IDs and row order.", | |
| "input_verification": "Full SHA256 before/after; every encoded text roundtrip; all sequence IDs checked; merged offsets/sizes/counts checked.", | |
| "test_and_blind_access": False, "corpus_ready_for_lm_training": cm.get("ready_for_lm_training", False), | |
| "corpus_quality_mode": cm.get("quality_mode"), "elapsed_seconds": time.monotonic() - started, | |
| "purpose": "V4 fixed 8K base LM data; capacity redistribution experiment, not vocabulary speed optimization."}) | |
| # Delete only these verified temporary parts inside this output. | |
| if parts.resolve().parent != output.resolve(): | |
| raise ValueError("Unexpected temporary part path") | |
| for job in jobs: | |
| for path in part_paths(job).values(): | |
| if path.resolve().parent != parts.resolve(): | |
| raise ValueError("Unexpected temporary file path") | |
| path.unlink() | |
| parts.rmdir() | |
| monitor.update("complete") | |
| index, report = prepare_v4_indexes(output, index_dir, checks) | |
| print(json.dumps({"tokens": exported, "windows": index["splits"], "loader_checks": report}, ensure_ascii=False, indent=2), flush=True) | |
| return index, report | |
| def main(): | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument("--config", type=Path, default=ROOT / "configs/data-v4.toml") | |
| parser.add_argument("--resume", action="store_true") | |
| parser.add_argument("--dry-run", action="store_true") | |
| args = parser.parse_args() | |
| run(args.config, args.resume, args.dry_run) | |
| if __name__ == "__main__": | |
| main() | |