@tuned-tensor/cli 0.13.1-beta.5 → 0.13.1-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,63 +1,462 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import hashlib
4
+ import json
5
+ import math
6
+ import os
3
7
  from pathlib import Path
8
+ import signal
9
+ import time
10
+ from typing import Any
4
11
 
12
+ import numpy as np
5
13
  import torch
6
14
  from tokenizers import Tokenizer
7
15
  from torch.nn import functional as F
8
16
 
9
- from common import load_config, require_cuda, write_json
10
- from data import corpus_hash, decoded_byte_count, example_pairs, smoke_corpus, window_tokens
11
- from model import FoundationGPT, bits_per_byte, model_config_from_depth, param_count, save_model
17
+ from checkpoint import CheckpointManager
18
+ from common import append_jsonl, ensure_private_directory, load_config, make_private_file, require_cuda, write_json
19
+ from data import END, corpus_hash, corpus_manifest, example_pairs, iter_corpus_documents, smoke_corpus
20
+ from model import FoundationGPT, model_config_from_depth, param_count, save_model
21
+
22
+
23
+ def stable_fingerprint(value: Any) -> str:
24
+ return hashlib.sha256(
25
+ json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8"),
26
+ ).hexdigest()
27
+
28
+
29
+ def file_sha256(path: str | Path) -> str:
30
+ digest = hashlib.sha256()
31
+ with Path(path).open("rb") as source:
32
+ while chunk := source.read(1_048_576):
33
+ digest.update(chunk)
34
+ return digest.hexdigest()
35
+
36
+
37
+ def source_signature(config: dict[str, Any]) -> dict[str, Any]:
38
+ corpus_path = config.get("corpus_path")
39
+ if corpus_path:
40
+ return {
41
+ "kind": "files",
42
+ "manifest": corpus_manifest(corpus_path),
43
+ "max_chars": int(config.get("max_chars") or 0),
44
+ }
45
+ examples = example_pairs(config.get("examples") or [])
46
+ corpus = smoke_corpus(
47
+ examples,
48
+ int(config.get("max_chars") or 20_000),
49
+ extra=str(config.get("system_prompt") or ""),
50
+ )
51
+ return {"kind": "smoke", "sha256": corpus_hash(corpus), "chars": len(corpus)}
52
+
53
+
54
+ def prepare_token_cache(
55
+ tokenizer: Tokenizer,
56
+ tokenizer_path: Path,
57
+ config: dict[str, Any],
58
+ output_dir: Path,
59
+ ) -> tuple[np.memmap, dict[str, Any]]:
60
+ cache_path = output_dir / "pretrain-tokens.bin"
61
+ metadata_path = output_dir / "pretrain-tokens.json"
62
+ signature = {
63
+ "source": source_signature(config),
64
+ "tokenizer_sha256": file_sha256(tokenizer_path),
65
+ }
66
+ if cache_path.is_file() and metadata_path.is_file():
67
+ try:
68
+ metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
69
+ except json.JSONDecodeError:
70
+ metadata = {}
71
+ expected_size = int(metadata.get("tokens", 0)) * np.dtype(np.uint32).itemsize
72
+ if (
73
+ metadata.get("signature") == signature
74
+ and expected_size == cache_path.stat().st_size
75
+ and isinstance(metadata.get("tokens_sha256"), str)
76
+ and file_sha256(cache_path) == metadata["tokens_sha256"]
77
+ ):
78
+ return np.memmap(cache_path, dtype=np.uint32, mode="r"), metadata
79
+
80
+ partial_path = output_dir / ".pretrain-tokens.partial"
81
+ partial_metadata_path = output_dir / ".pretrain-tokens.partial.json"
82
+ partial_metadata: dict[str, Any] = {}
83
+ if partial_metadata_path.is_file():
84
+ try:
85
+ partial_metadata = json.loads(partial_metadata_path.read_text(encoding="utf-8"))
86
+ except json.JSONDecodeError:
87
+ partial_metadata = {}
88
+ if cache_path.is_file() and partial_metadata.get("signature") == signature:
89
+ expected_size = int(partial_metadata.get("bytes", -1))
90
+ expected_tokens = int(partial_metadata.get("tokens", 0))
91
+ expected_hash = partial_metadata.get("tokens_sha256")
92
+ if (
93
+ expected_size == cache_path.stat().st_size
94
+ and expected_tokens > 0
95
+ and expected_tokens * np.dtype(np.uint32).itemsize == expected_size
96
+ and isinstance(expected_hash, str)
97
+ and file_sha256(cache_path) == expected_hash
98
+ ):
99
+ metadata = {
100
+ "signature": signature,
101
+ "tokens": int(partial_metadata["tokens"]),
102
+ "documents": int(partial_metadata["documents"]),
103
+ "source_documents": int(partial_metadata.get("source_documents", partial_metadata["documents"])),
104
+ "tokens_sha256": str(partial_metadata["tokens_sha256"]),
105
+ }
106
+ write_json(metadata_path, metadata)
107
+ partial_metadata_path.unlink(missing_ok=True)
108
+ return np.memmap(cache_path, dtype=np.uint32, mode="r"), metadata
109
+ if partial_metadata.get("signature") != signature or not partial_path.is_file():
110
+ partial_path.unlink(missing_ok=True)
111
+ partial_metadata_path.unlink(missing_ok=True)
112
+ partial_metadata = {}
113
+
114
+ digest = hashlib.sha256()
115
+ token_count = int(partial_metadata.get("tokens", 0))
116
+ document_count = int(partial_metadata.get("documents", 0))
117
+ source_documents = int(partial_metadata.get("source_documents", document_count))
118
+ recorded_bytes = int(partial_metadata.get("bytes", 0))
119
+ if partial_path.is_file():
120
+ partial_size = partial_path.stat().st_size
121
+ valid_partial = (
122
+ 0 <= recorded_bytes <= partial_size
123
+ and token_count >= 0
124
+ and token_count * np.dtype(np.uint32).itemsize == recorded_bytes
125
+ and isinstance(partial_metadata.get("tokens_sha256"), str)
126
+ )
127
+ if valid_partial:
128
+ with partial_path.open("r+b") as partial:
129
+ partial.truncate(recorded_bytes)
130
+ with partial_path.open("rb") as partial:
131
+ while chunk := partial.read(1_048_576):
132
+ digest.update(chunk)
133
+ valid_partial = digest.hexdigest() == partial_metadata["tokens_sha256"]
134
+ if not valid_partial:
135
+ partial_path.unlink(missing_ok=True)
136
+ partial_metadata_path.unlink(missing_ok=True)
137
+ partial_metadata = {}
138
+ digest = hashlib.sha256()
139
+ token_count = 0
140
+ document_count = 0
141
+ source_documents = 0
142
+ recorded_bytes = 0
143
+ corpus_path = config.get("corpus_path")
144
+ if corpus_path:
145
+ documents = iter_corpus_documents(corpus_path, int(config.get("max_chars") or 0))
146
+ else:
147
+ examples = example_pairs(config.get("examples") or [])
148
+ documents = iter([smoke_corpus(
149
+ examples,
150
+ int(config.get("max_chars") or 20_000),
151
+ extra=str(config.get("system_prompt") or ""),
152
+ )])
153
+ separator = tokenizer.token_to_id(END)
154
+
155
+ def persist_partial(destination: Any) -> None:
156
+ destination.flush()
157
+ os.fsync(destination.fileno())
158
+ write_json(partial_metadata_path, {
159
+ "signature": signature,
160
+ "tokens": token_count,
161
+ "documents": document_count,
162
+ "source_documents": source_documents,
163
+ "bytes": destination.tell(),
164
+ "tokens_sha256": digest.hexdigest(),
165
+ })
166
+
167
+ mode = "ab" if partial_path.is_file() else "wb"
168
+ with partial_path.open(mode) as destination:
169
+ os.fchmod(destination.fileno(), 0o600)
170
+ for source_index, document in enumerate(documents):
171
+ if source_index < source_documents:
172
+ continue
173
+ source_documents += 1
174
+ ids = tokenizer.encode(document).ids
175
+ if separator is not None:
176
+ ids.append(separator)
177
+ if not ids:
178
+ continue
179
+ values = np.asarray(ids, dtype=np.uint32)
180
+ payload = values.tobytes()
181
+ destination.write(payload)
182
+ digest.update(payload)
183
+ token_count += len(ids)
184
+ document_count += 1
185
+ if document_count % 1024 == 0:
186
+ persist_partial(destination)
187
+ persist_partial(destination)
188
+ if source_signature(config) != signature["source"]:
189
+ partial_path.unlink(missing_ok=True)
190
+ partial_metadata_path.unlink(missing_ok=True)
191
+ raise RuntimeError("Foundation corpus changed while the token cache was being built.")
192
+ if token_count < 2:
193
+ partial_path.unlink(missing_ok=True)
194
+ partial_metadata_path.unlink(missing_ok=True)
195
+ raise ValueError("Foundation pretraining corpus produced fewer than two tokens.")
196
+ os.replace(partial_path, cache_path)
197
+ make_private_file(cache_path)
198
+ metadata = {
199
+ "signature": signature,
200
+ "tokens": token_count,
201
+ "documents": document_count,
202
+ "source_documents": source_documents,
203
+ "tokens_sha256": digest.hexdigest(),
204
+ }
205
+ write_json(metadata_path, metadata)
206
+ partial_metadata_path.unlink(missing_ok=True)
207
+ return np.memmap(cache_path, dtype=np.uint32, mode="r"), metadata
208
+
209
+
210
+ def coprime_stride(windows: int, seed: int) -> int:
211
+ if windows <= 1:
212
+ return 1
213
+ candidate = max(1, (2 * abs(seed) + 1) % windows)
214
+ while math.gcd(candidate, windows) != 1:
215
+ candidate = (candidate + 1) % windows or 1
216
+ return candidate
217
+
218
+
219
+ def token_batch(
220
+ tokens: np.memmap,
221
+ *,
222
+ samples_consumed: int,
223
+ batch_size: int,
224
+ sequence_length: int,
225
+ seed: int,
226
+ ) -> np.ndarray:
227
+ if len(tokens) < sequence_length + 1:
228
+ repeats = math.ceil((sequence_length + 1) / len(tokens))
229
+ expanded = np.tile(np.asarray(tokens), repeats)
230
+ return np.stack([expanded[:sequence_length + 1]] * batch_size)
231
+ windows = max(1, (len(tokens) - 1) // sequence_length)
232
+ stride = coprime_stride(windows, seed)
233
+ offset = abs(seed) % windows
234
+ rows: list[np.ndarray] = []
235
+ for index in range(batch_size):
236
+ window = ((samples_consumed + index) * stride + offset) % windows
237
+ start = window * sequence_length
238
+ rows.append(np.asarray(tokens[start:start + sequence_length + 1]))
239
+ return np.stack(rows)
240
+
241
+
242
+ def learning_rate_multiplier(step: int, *, warmup_steps: int, total_steps: int, min_ratio: float) -> float:
243
+ if warmup_steps > 0 and step < warmup_steps:
244
+ return max((step + 1) / warmup_steps, 1 / max(warmup_steps, 1))
245
+ decay_steps = max(total_steps - warmup_steps, 1)
246
+ progress = min(max((step - warmup_steps) / decay_steps, 0.0), 1.0)
247
+ cosine = 0.5 * (1.0 + math.cos(math.pi * progress))
248
+ return min_ratio + (1.0 - min_ratio) * cosine
249
+
250
+
251
+ class StopRequest:
252
+ signal_number: int | None = None
253
+
254
+ def install(self) -> None:
255
+ def request_stop(number: int, _frame: Any) -> None:
256
+ self.signal_number = number
257
+
258
+ signal.signal(signal.SIGINT, request_stop)
259
+ signal.signal(signal.SIGTERM, request_stop)
12
260
 
13
261
 
14
262
  def main(argv: list[str] | None = None) -> None:
15
263
  config = load_config(argv)
16
264
  require_cuda("pretrain")
17
265
  device = torch.device("cuda")
18
- tokenizer = Tokenizer.from_file(str(Path(config["tokenizer_dir"]) / "tokenizer.json"))
19
- examples = example_pairs(config.get("examples") or [])
20
- corpus = smoke_corpus(examples, int(config.get("max_chars") or 20_000), extra=str(config.get("system_prompt") or ""))
21
- token_ids = tokenizer.encode(corpus).ids
266
+ torch.set_float32_matmul_precision("high")
267
+ seed = int(config.get("seed") or 1337)
268
+ torch.manual_seed(seed)
269
+ torch.cuda.manual_seed_all(seed)
270
+
271
+ tokenizer_path = Path(config["tokenizer_dir"]) / "tokenizer.json"
272
+ tokenizer = Tokenizer.from_file(str(tokenizer_path))
22
273
  sequence_length = int(config.get("sequence_length") or 64)
23
- windows = window_tokens(token_ids, sequence_length)
24
274
  depth = int(config.get("depth") or 2)
25
275
  model_config = model_config_from_depth(depth, tokenizer.get_vocab_size(), sequence_length)
26
276
  model = FoundationGPT(model_config).to(device)
27
- optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)
28
- batch_size = max(1, int(config.get("batch_size") or 2))
277
+
278
+ output_dir = ensure_private_directory(config["output_dir"])
279
+ work_dir = ensure_private_directory(config.get("work_dir") or output_dir)
280
+ tokens, token_metadata = prepare_token_cache(tokenizer, tokenizer_path, config, work_dir)
29
281
  steps = max(1, int(config.get("steps") or 2))
282
+ batch_size = max(1, int(config.get("batch_size") or 2))
283
+ accumulation_steps = max(1, int(config.get("gradient_accumulation_steps") or 1))
284
+ learning_rate = float(config.get("learning_rate") or 3e-4)
285
+ weight_decay = float(config.get("weight_decay") if config.get("weight_decay") is not None else 0.1)
286
+ configured_warmup = config.get("warmup_steps")
287
+ warmup_steps = min(
288
+ max(0, int(configured_warmup)) if configured_warmup is not None else min(2000, max(1, steps // 100)),
289
+ steps,
290
+ )
291
+ min_lr_ratio = float(config.get("min_lr_ratio") if config.get("min_lr_ratio") is not None else 0.1)
292
+ gradient_clip = float(config.get("gradient_clip") or 1.0)
293
+ bf16 = bool(config.get("bf16", True))
294
+ if bf16 and not torch.cuda.is_bf16_supported():
295
+ raise RuntimeError("Foundation BF16 training was requested, but this CUDA device does not support BF16.")
296
+
297
+ optimizer = torch.optim.AdamW(
298
+ model.parameters(),
299
+ lr=learning_rate,
300
+ weight_decay=weight_decay,
301
+ fused=True,
302
+ )
303
+ scheduler = torch.optim.lr_scheduler.LambdaLR(
304
+ optimizer,
305
+ lambda current: learning_rate_multiplier(
306
+ current,
307
+ warmup_steps=warmup_steps,
308
+ total_steps=steps,
309
+ min_ratio=min_lr_ratio,
310
+ ),
311
+ )
312
+ recovery_config = {
313
+ "model": model_config,
314
+ "steps": steps,
315
+ "batch_size": batch_size,
316
+ "gradient_accumulation_steps": accumulation_steps,
317
+ "learning_rate": learning_rate,
318
+ "weight_decay": weight_decay,
319
+ "warmup_steps": warmup_steps,
320
+ "min_lr_ratio": min_lr_ratio,
321
+ "gradient_clip": gradient_clip,
322
+ "bf16": bf16,
323
+ "seed": seed,
324
+ "token_cache": token_metadata,
325
+ }
326
+ manager = CheckpointManager(
327
+ work_dir,
328
+ stable_fingerprint(recovery_config),
329
+ keep=int(config.get("keep_checkpoints") or 3),
330
+ backup_dir=config.get("checkpoint_backup_dir"),
331
+ )
332
+ start_step = 0
333
+ samples_consumed = 0
334
+ tokens_seen = 0
30
335
  last_loss = 0.0
31
- last_bpb = 0.0
336
+ if bool(config.get("resume", True)):
337
+ resumed = manager.load(model, optimizer, scheduler, device)
338
+ if resumed is not None:
339
+ start_step = resumed.step
340
+ samples_consumed = resumed.samples_consumed
341
+ tokens_seen = resumed.tokens_seen
342
+ last_loss = resumed.last_loss
343
+ print(json.dumps({"event": "resumed", "step": start_step, "checkpoint": str(resumed.path)}), flush=True)
344
+
345
+ checkpoint_steps = max(1, int(config.get("checkpoint_interval_steps") or 500))
346
+ checkpoint_seconds = max(1, int(config.get("checkpoint_interval_seconds") or 1800))
347
+ log_steps = max(1, int(config.get("log_interval_steps") or 10))
348
+ metrics_path = work_dir / "training-metrics.jsonl"
349
+ stop = StopRequest()
350
+ stop.install()
351
+ started = time.monotonic()
352
+ last_log_time = started
353
+ last_log_tokens = tokens_seen
354
+ last_checkpoint_time = started
32
355
  model.train()
33
- for step in range(steps):
34
- batch = windows[step % len(windows): step % len(windows) + batch_size]
35
- if len(batch) < batch_size:
36
- batch = (batch + windows)[:batch_size]
37
- tensor = torch.tensor(batch, dtype=torch.long, device=device)
38
- inputs = tensor[:, :-1]
39
- labels = tensor[:, 1:]
40
- logits = model(inputs)
41
- loss = F.cross_entropy(logits.reshape(-1, logits.size(-1)), labels.reshape(-1))
356
+
357
+ for step in range(start_step, steps):
42
358
  optimizer.zero_grad(set_to_none=True)
43
- loss.backward()
359
+ accumulated_loss = 0.0
360
+ for _microstep in range(accumulation_steps):
361
+ rows = token_batch(
362
+ tokens,
363
+ samples_consumed=samples_consumed,
364
+ batch_size=batch_size,
365
+ sequence_length=sequence_length,
366
+ seed=seed,
367
+ )
368
+ tensor = torch.from_numpy(rows.astype(np.int64, copy=False)).to(device=device, non_blocking=True)
369
+ inputs = tensor[:, :-1]
370
+ labels = tensor[:, 1:]
371
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16, enabled=bf16):
372
+ logits = model(inputs)
373
+ raw_loss = F.cross_entropy(logits.reshape(-1, logits.size(-1)), labels.reshape(-1))
374
+ loss = raw_loss / accumulation_steps
375
+ if not bool(torch.isfinite(raw_loss)):
376
+ manager.save(
377
+ model, optimizer, scheduler,
378
+ step=step,
379
+ samples_consumed=samples_consumed,
380
+ tokens_seen=tokens_seen,
381
+ last_loss=last_loss,
382
+ reason="non-finite-loss",
383
+ )
384
+ raise FloatingPointError(f"Non-finite pretraining loss at step {step}: {raw_loss.item()}")
385
+ loss.backward()
386
+ accumulated_loss += float(raw_loss.item())
387
+ samples_consumed += batch_size
388
+ tokens_seen += int(labels.numel())
389
+ gradient_norm = float(torch.nn.utils.clip_grad_norm_(model.parameters(), gradient_clip).item())
390
+ if not math.isfinite(gradient_norm):
391
+ manager.save(
392
+ model, optimizer, scheduler,
393
+ step=step,
394
+ samples_consumed=samples_consumed - batch_size * accumulation_steps,
395
+ tokens_seen=tokens_seen - batch_size * sequence_length * accumulation_steps,
396
+ last_loss=last_loss,
397
+ reason="non-finite-gradient",
398
+ )
399
+ raise FloatingPointError(f"Non-finite gradient norm at step {step}: {gradient_norm}")
44
400
  optimizer.step()
45
- last_loss = float(loss.item())
46
- byte_count = decoded_byte_count(tokenizer, labels.detach().cpu().tolist())
47
- last_bpb = bits_per_byte(loss.detach() * labels.numel(), max(byte_count, 1))
401
+ scheduler.step()
402
+ completed_step = step + 1
403
+ last_loss = accumulated_loss / accumulation_steps
404
+ now = time.monotonic()
405
+
406
+ if completed_step % log_steps == 0 or completed_step == steps:
407
+ interval = max(now - last_log_time, 1e-6)
408
+ record = {
409
+ "event": "progress",
410
+ "step": completed_step,
411
+ "total_steps": steps,
412
+ "loss": last_loss,
413
+ "learning_rate": float(scheduler.get_last_lr()[0]),
414
+ "gradient_norm": gradient_norm,
415
+ "tokens_seen": tokens_seen,
416
+ "tokens_per_second": (tokens_seen - last_log_tokens) / interval,
417
+ "elapsed_seconds": now - started,
418
+ "cuda_allocated_bytes": int(torch.cuda.memory_allocated(device)),
419
+ "cuda_reserved_bytes": int(torch.cuda.memory_reserved(device)),
420
+ }
421
+ append_jsonl(metrics_path, record)
422
+ print(json.dumps(record, separators=(",", ":")), flush=True)
423
+ last_log_time = now
424
+ last_log_tokens = tokens_seen
425
+
426
+ checkpoint_due = (
427
+ completed_step % checkpoint_steps == 0
428
+ or now - last_checkpoint_time >= checkpoint_seconds
429
+ or completed_step == steps
430
+ or stop.signal_number is not None
431
+ )
432
+ if checkpoint_due:
433
+ reason = "signal" if stop.signal_number is not None else ("complete" if completed_step == steps else "periodic")
434
+ checkpoint = manager.save(
435
+ model, optimizer, scheduler,
436
+ step=completed_step,
437
+ samples_consumed=samples_consumed,
438
+ tokens_seen=tokens_seen,
439
+ last_loss=last_loss,
440
+ reason=reason,
441
+ )
442
+ print(json.dumps({"event": "checkpoint", "step": completed_step, "path": str(checkpoint), "reason": reason}), flush=True)
443
+ last_checkpoint_time = now
444
+ if stop.signal_number is not None:
445
+ raise SystemExit(128 + stop.signal_number)
48
446
 
49
- output_dir = Path(config["output_dir"])
50
447
  save_model(model, output_dir)
51
448
  write_json(output_dir / "metrics.json", {
52
449
  "ok": True,
53
450
  "steps": steps,
54
451
  "loss": last_loss,
55
- "bpb": last_bpb,
56
452
  "parameters": param_count(model),
57
453
  "depth": depth,
58
454
  "width": model_config["width"],
59
455
  "heads": model_config["heads"],
60
- "corpus_sha256": corpus_hash(corpus),
456
+ "tokens_seen": tokens_seen,
457
+ "training_tokens": int(token_metadata["tokens"]),
458
+ "token_cache_sha256": token_metadata["tokens_sha256"],
459
+ "bf16": bf16,
61
460
  "device": str(device),
62
461
  })
63
462
 
@@ -1,23 +1,47 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from common import ensure_private_directory, load_config, make_private_file, write_json
4
- from data import corpus_hash, example_pairs, smoke_corpus, train_tokenizer
4
+ from data import (
5
+ corpus_documents_hash,
6
+ corpus_hash,
7
+ corpus_manifest,
8
+ example_pairs,
9
+ iter_corpus_documents,
10
+ smoke_corpus,
11
+ train_tokenizer,
12
+ )
5
13
 
6
14
 
7
15
  def main(argv: list[str] | None = None) -> None:
8
16
  config = load_config(argv)
9
17
  output_dir = ensure_private_directory(config["output_dir"])
10
18
  examples = example_pairs(config.get("examples") or [])
11
- corpus = smoke_corpus(examples, int(config.get("max_chars") or 20_000), extra=str(config.get("system_prompt") or ""))
12
- tokenizer = train_tokenizer(corpus, int(config.get("vocab_size") or 256))
19
+ max_chars = int(config.get("max_chars") or 20_000)
20
+ corpus_path = config.get("corpus_path")
21
+ if corpus_path:
22
+ manifest_before = corpus_manifest(corpus_path)
23
+ corpus_sha256, corpus_chars = corpus_documents_hash(iter_corpus_documents(corpus_path, max_chars))
24
+ tokenizer = train_tokenizer(
25
+ iter_corpus_documents(corpus_path, max_chars),
26
+ int(config.get("vocab_size") or 256),
27
+ )
28
+ if corpus_manifest(corpus_path) != manifest_before:
29
+ raise RuntimeError("Foundation corpus changed while the tokenizer was being trained.")
30
+ else:
31
+ corpus = smoke_corpus(examples, max_chars, extra=str(config.get("system_prompt") or ""))
32
+ corpus_sha256 = corpus_hash(corpus)
33
+ corpus_chars = len(corpus)
34
+ tokenizer = train_tokenizer(corpus, int(config.get("vocab_size") or 256))
13
35
  tokenizer_path = output_dir / "tokenizer.json"
14
36
  tokenizer.save(str(tokenizer_path))
15
37
  make_private_file(tokenizer_path)
16
38
  write_json(output_dir / "metrics.json", {
17
39
  "ok": True,
18
40
  "vocab_size": tokenizer.get_vocab_size(),
19
- "corpus_chars": len(corpus),
20
- "corpus_sha256": corpus_hash(corpus),
41
+ "corpus_chars": corpus_chars,
42
+ "corpus_sha256": corpus_sha256,
43
+ "corpus_path": str(corpus_path) if corpus_path else None,
44
+ "corpus_manifest": manifest_before if corpus_path else None,
21
45
  "tokenizer_path": str(tokenizer_path),
22
46
  })
23
47