File size: 10,599 Bytes
4d3248c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
"""
Extend an existing SentencePiece model by appending new tokens while keeping
the original token order and indices intact.

The workflow trains a temporary tokenizer on additional corpora, harvests
candidate pieces, filters out ones already present in the base model, and
appends the remaining pieces (in rank order) to reach the requested target
vocabulary size.
"""

from __future__ import annotations

import argparse
import json
import sys
import tempfile
from pathlib import Path
from typing import Iterable

import sentencepiece as spm

# Ensure we can import the legacy protobuf builder helpers used by SentencePiece.
try:  # pragma: no cover - import side-effect
    from google.protobuf.internal import builder as _builder  # type: ignore  # noqa: F401
except ImportError:  # pragma: no cover - fallback shim
    from tools.tokenizer import protobuf_builder_compat as _builder  # noqa: F401
    sys.modules["google.protobuf.internal.builder"] = _builder

from sentencepiece import sentencepiece_model_pb2 as sp_model  # noqa: E402

DEFAULT_TARGET_SIZE = 24_000
DEFAULT_EXTRA_FACTOR = 2.0


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Append new tokens to an existing SentencePiece model.")
    parser.add_argument(
        "--base-model",
        type=Path,
        required=True,
        help="Path to the existing SentencePiece model (.model).",
    )
    parser.add_argument(
        "--manifests",
        type=Path,
        nargs="+",
        required=True,
        help="JSONL manifests (each line must contain a 'text' field).",
    )
    parser.add_argument(
        "--output-model",
        type=Path,
        help="Destination for the extended .model (defaults to <base>_extended.model).",
    )
    parser.add_argument(
        "--output-vocab",
        type=Path,
        help="Destination for the extended .vocab (defaults to match output-model prefix).",
    )
    parser.add_argument(
        "--target-size",
        type=int,
        default=DEFAULT_TARGET_SIZE,
        help=f"Desired total vocabulary size after extension (default: {DEFAULT_TARGET_SIZE}).",
    )
    parser.add_argument(
        "--candidate-size",
        type=int,
        help="Temporary tokenizer vocab size used to mine new tokens (default: target-size * extra-factor).",
    )
    parser.add_argument(
        "--extra-factor",
        type=float,
        default=DEFAULT_EXTRA_FACTOR,
        help="Multiplier applied to target-size when computing candidate-size (ignored if candidate-size set).",
    )
    parser.add_argument(
        "--character-coverage",
        type=float,
        default=1.0,
        help="SentencePiece character coverage for the temporary tokenizer.",
    )
    parser.add_argument(
        "--model-type",
        choices=["bpe", "unigram"],
        default="bpe",
        help="SentencePiece model type for the temporary tokenizer.",
    )
    parser.add_argument(
        "--keep-spaces",
        action="store_true",
        help="Preserve consecutive spaces by disabling remove_extra_whitespaces.",
    )
    return parser.parse_args()


def validate_inputs(base_model: Path, manifests: Iterable[Path]) -> None:
    if not base_model.exists():
        raise FileNotFoundError(f"Base model not found: {base_model}")
    missing = [str(p) for p in manifests if not p.exists()]
    if missing:
        raise FileNotFoundError(f"Missing manifest(s): {', '.join(missing)}")


def collect_corpus(manifests: Iterable[Path]) -> tuple[Path, int]:
    """Write all text fields from the manifests into a temporary corpus file."""
    total = 0
    tmp = tempfile.NamedTemporaryFile(mode="w", delete=False, encoding="utf-8", suffix=".txt")
    path = Path(tmp.name)
    try:
        with tmp:
            for manifest in manifests:
                with manifest.open("r", encoding="utf-8") as handle:
                    for line in handle:
                        if not line.strip():
                            continue
                        record = json.loads(line)
                        text = record.get("text", "")
                        text = text.strip()
                        if not text:
                            continue
                        tmp.write(text + "\n")
                        total += 1
    except Exception:
        path.unlink(missing_ok=True)
        raise
    if total == 0:
        path.unlink(missing_ok=True)
        raise RuntimeError("No usable text samples found in manifests.")
    return path, total


def train_candidate_tokenizer(
    corpus: Path,
    vocab_size: int,
    model_type: str,
    character_coverage: float,
    keep_spaces: bool = False,
) -> sp_model.ModelProto:
    """Train a temporary tokenizer and return its ModelProto."""
    with tempfile.TemporaryDirectory() as tmpdir:
        prefix = Path(tmpdir) / "candidate"
        spm.SentencePieceTrainer.Train(
            input=str(corpus),
            model_prefix=str(prefix),
            vocab_size=vocab_size,
            character_coverage=character_coverage,
            model_type=model_type,
            train_extremely_large_corpus=True,
            input_sentence_size=0,
            shuffle_input_sentence=True,
            bos_id=0,
            eos_id=1,
            unk_id=2,
            pad_id=-1,
            remove_extra_whitespaces=not keep_spaces,
        )
        candidate_model_path = prefix.with_suffix(".model")
        proto = sp_model.ModelProto()
        proto.ParseFromString(candidate_model_path.read_bytes())
    return proto


def load_model(path: Path) -> sp_model.ModelProto:
    proto = sp_model.ModelProto()
    proto.ParseFromString(path.read_bytes())
    return proto


def append_tokens(
    base_proto: sp_model.ModelProto,
    candidate_proto: sp_model.ModelProto,
    target_size: int,
) -> list[sp_model.ModelProto.SentencePiece]:
    """Append new pieces from candidate_proto to base_proto to reach target_size."""
    base_count = len(base_proto.pieces)
    if base_count >= target_size:
        raise ValueError(f"Target size ({target_size}) must exceed current vocabulary ({base_count}).")

    needed = target_size - base_count
    allowed_types = {
        sp_model.ModelProto.SentencePiece.Type.NORMAL,
        sp_model.ModelProto.SentencePiece.Type.USER_DEFINED,
    }

    existing_texts = {piece.piece for piece in base_proto.pieces}
    appended: list[sp_model.ModelProto.SentencePiece] = []

    candidate_lookup = {}
    for piece in candidate_proto.pieces:
        if piece.type in allowed_types and piece.piece not in candidate_lookup:
            candidate_lookup[piece.piece] = piece

    def add_piece(source_piece: sp_model.ModelProto.SentencePiece) -> None:
        new_piece = base_proto.pieces.add()
        new_piece.piece = source_piece.piece
        new_piece.score = source_piece.score
        new_piece.type = source_piece.type
        existing_texts.add(new_piece.piece)
        appended.append(new_piece)

    # Ensure single lowercase ASCII letters and digits exist so we can encode contractions and numbers.
    for ch in "abcdefghijklmnopqrstuvwxyz0123456789":
        if ch in existing_texts:
            continue
        piece = candidate_lookup.get(ch)
        if piece is None:
            continue
        add_piece(piece)
        if len(appended) >= needed:
            break

    if len(appended) < needed:
        for piece in candidate_proto.pieces:
            if piece.type not in allowed_types:
                continue
            if piece.piece in existing_texts:
                continue
            add_piece(piece)
            if len(appended) >= needed:
                break

    if len(appended) < needed:
        raise RuntimeError(
            f"Only found {len(appended)} new tokens (need {needed}). "
            "Increase --candidate-size or supply more training text."
        )

    base_proto.trainer_spec.vocab_size = len(base_proto.pieces)
    return appended


def write_vocab_file(proto: sp_model.ModelProto, path: Path) -> None:
    with path.open("w", encoding="utf-8") as handle:
        for piece in proto.pieces:
            handle.write(f"{piece.piece}\t{piece.score}\n")


def main() -> int:
    args = parse_args()
    validate_inputs(args.base_model, args.manifests)

    base_proto = load_model(args.base_model)
    base_size = len(base_proto.pieces)
    if base_proto.trainer_spec.vocab_size != base_size:
        print(
            f"[extend_bpe] Warning: trainer_spec.vocab_size ({base_proto.trainer_spec.vocab_size}) "
            f"differs from actual pieces ({base_size}). Using piece count.",
            file=sys.stderr,
        )

    target_size = args.target_size
    if target_size <= base_size:
        raise ValueError(
            f"Target size ({target_size}) must be greater than current vocabulary ({base_size})."
        )

    candidate_size = args.candidate_size or int(target_size * args.extra_factor)
    candidate_size = max(candidate_size, target_size + 1024)  # ensure some headroom

    corpus_path, total_samples = collect_corpus(args.manifests)
    print(f"[extend_bpe] Collected {total_samples} samples into {corpus_path}")
    try:
        print(
            f"[extend_bpe] Training candidate tokenizer (vocab_size={candidate_size}, "
            f"model_type={args.model_type})"
        )
        candidate_proto = train_candidate_tokenizer(
            corpus=corpus_path,
            vocab_size=candidate_size,
            model_type=args.model_type,
            character_coverage=args.character_coverage,
            keep_spaces=args.keep_spaces,
        )
    finally:
        corpus_path.unlink(missing_ok=True)

    appended = append_tokens(base_proto, candidate_proto, target_size=target_size)
    print(f"[extend_bpe] Appended {len(appended)} new tokens (total={len(base_proto.pieces)})")

    if args.keep_spaces:
        base_proto.normalizer_spec.remove_extra_whitespaces = False
        print("[extend_bpe] Disabled remove_extra_whitespaces in the final model.")

    output_model = args.output_model or args.base_model.with_name(args.base_model.stem + "_extended.model")
    output_vocab = args.output_vocab or output_model.with_suffix(".vocab")

    output_model.parent.mkdir(parents=True, exist_ok=True)
    output_model.write_bytes(base_proto.SerializeToString())
    write_vocab_file(base_proto, output_vocab)

    print(f"[extend_bpe] Wrote extended model to {output_model}")
    print(f"[extend_bpe] Wrote extended vocab to {output_vocab}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())