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

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.
@@ -51,19 +51,37 @@ TT Local currently ships two such methods:
51
51
  rejects `nproc_per_node` values above 1 and unsupported steps such as
52
52
  `compare` before creating run artifacts.
53
53
 
54
- The foundation engine is a readable lifecycle baseline inspired by nanochat,
55
- not a port of nanochat's data, distributed, optimizer, or compute-optimal
56
- training stack. Its bounded pretraining corpus repeats the foundation spec's
57
- prompt and examples, and chat evaluation reuses those examples. RL uses a
54
+ The foundation engine is a readable single-GPU trainer inspired by nanochat,
55
+ not a port of nanochat's distributed or compute-optimal training stack. It can
56
+ stream configured text/JSONL shards into a deterministic token cache, use a
57
+ distinct held-out BPB corpus, and atomically recover model, optimizer,
58
+ scheduler, RNG, counters, and token position. Without a configured corpus it
59
+ retains the bounded prompt/example smoke path. Chat evaluation reuses the spec
60
+ examples. RL uses a
58
61
  single seeded on-policy sample per step with a sparse last-number exact reward
59
62
  and requires every expected output to contain a number. BPB is normalized over
60
63
  decoded target bytes from each evaluated window, but the corpus remains
61
- example-derived. The resulting metrics prove stage wiring and can demonstrate
62
- deliberate overfitting; they do not measure held-out model capability.
64
+ example-derived unless `validation_path` is configured. Smoke metrics prove
65
+ stage wiring and can demonstrate deliberate overfitting; they do not measure
66
+ held-out model capability.
63
67
 
64
68
  Do not mix those lockfiles. Each engine hashes its own `pyproject.toml` and
65
69
  `uv.lock` into a distinct cache environment.
66
70
 
71
+ ## Spec-driven instruction contract
72
+
73
+ Adapter and foundation specs extend one engine-neutral behavior schema:
74
+ `name`, `description`, `system_prompt`, `guidelines`, `constraints`, and
75
+ `examples`. Node compiles the three instruction fields into one canonical
76
+ system message. The adapter dataset compiler and every foundation stage use
77
+ that same compiler, so tokenizer input, pretraining smoke data, chat SFT, RL,
78
+ and evaluation all receive guidelines and constraints consistently.
79
+
80
+ Engine-specific fields control execution only. Adapter specs add `base_model`,
81
+ LoRA hyperparameters, and optional prebuilt datasets. Foundation specs add
82
+ `engine: "foundation"` and the `foundation` training configuration. The
83
+ behavioral instruction remains the shared source of truth.
84
+
67
85
  ## Python boundary
68
86
 
69
87
  Node owns input validation, state, process lifecycle, manifests, and reports.
@@ -0,0 +1,108 @@
1
+ # Long foundation runs
2
+
3
+ The foundation engine can run recoverable, single-GPU pretraining from `.txt`
4
+ files or JSONL shards whose rows contain a string `text` field. Keep training
5
+ and validation data in different files or directories.
6
+
7
+ ## Spec
8
+
9
+ The long-run fields live under `foundation` in `tunedtensor.json`:
10
+
11
+ ```json
12
+ {
13
+ "corpus_path": "./data/train",
14
+ "validation_path": "./data/validation.jsonl",
15
+ "checkpoint_backup_dir": "/mnt/checkpoint-backup/my-run",
16
+ "depth": 20,
17
+ "vocab_size": 32000,
18
+ "tokenizer_max_chars": 100000000,
19
+ "max_chars": 50000000000,
20
+ "pretrain_steps": 200000,
21
+ "sequence_length": 1024,
22
+ "batch_size": 2,
23
+ "gradient_accumulation_steps": 16,
24
+ "learning_rate": 0.0003,
25
+ "weight_decay": 0.1,
26
+ "warmup_steps": 2000,
27
+ "min_lr_ratio": 0.1,
28
+ "gradient_clip": 1.0,
29
+ "bf16": true,
30
+ "checkpoint_interval_steps": 500,
31
+ "checkpoint_interval_seconds": 1800,
32
+ "keep_checkpoints": 3,
33
+ "log_interval_steps": 10,
34
+ "seed": 1337,
35
+ "finetune_steps": 100,
36
+ "rl_steps": 0,
37
+ "nproc_per_node": 1
38
+ }
39
+ ```
40
+
41
+ Relative paths are resolved beside the spec. `max_chars` is a deterministic
42
+ upper bound across the sorted corpus shards. Omit `corpus_path` only for the
43
+ small prompt/example smoke corpus.
44
+
45
+ Run the preflight, then use one stable, absolute run directory from the first
46
+ launch onward:
47
+
48
+ ```bash
49
+ tt doctor tunedtensor.json
50
+ tt pipeline run \
51
+ --spec /absolute/project/tunedtensor.json \
52
+ --resume /absolute/project/.tuned-tensor/foundation-runs/experiment-1
53
+ ```
54
+
55
+ `--resume` also starts a missing directory. On restart, verified completed
56
+ stages are skipped. An interrupted pretrain stage restores the newest complete
57
+ checkpoint with matching configuration. Changing model, optimizer, tokenizer,
58
+ data manifest, or schedule settings requires a new run directory.
59
+
60
+ Recovery data is stored under `pretrain/recovery/`:
61
+
62
+ - `pretrain-tokens.bin` and its source/tokenizer manifest;
63
+ - `training-metrics.jsonl` heartbeats;
64
+ - `checkpoints/step-*` atomic rolling checkpoints.
65
+
66
+ Each checkpoint contains model weights, AdamW and scheduler state, CPU and CUDA
67
+ RNG state, completed optimizer step, samples consumed, token count, and loss.
68
+ SIGINT or SIGTERM requests a step-boundary checkpoint. The CLI allows up to two
69
+ minutes for that checkpoint before forcibly stopping the process. SIGKILL or a
70
+ power loss falls back to the most recent periodic checkpoint.
71
+
72
+ ## User service
73
+
74
+ For unattended operation, run the resumable command under a user systemd
75
+ service. Replace every path with an absolute path and use the installed `tt`
76
+ path reported by `command -v tt`:
77
+
78
+ ```ini
79
+ [Unit]
80
+ Description=Tuned Tensor foundation pretraining
81
+ After=network-online.target
82
+
83
+ [Service]
84
+ Type=simple
85
+ WorkingDirectory=/absolute/project
86
+ ExecStart=/absolute/path/to/tt pipeline run --spec /absolute/project/tunedtensor.json --resume /absolute/project/.tuned-tensor/foundation-runs/experiment-1
87
+ Restart=on-failure
88
+ RestartSec=30
89
+ TimeoutStopSec=130
90
+ KillSignal=SIGTERM
91
+
92
+ [Install]
93
+ WantedBy=default.target
94
+ ```
95
+
96
+ Save it as `~/.config/systemd/user/tt-foundation.service`, then run:
97
+
98
+ ```bash
99
+ systemctl --user daemon-reload
100
+ systemctl --user enable --now tt-foundation.service
101
+ journalctl --user -fu tt-foundation.service
102
+ ```
103
+
104
+ Enable user lingering if the job must survive logout. Use a UPS for power-loss
105
+ protection, and put `checkpoint_backup_dir` on a different physical disk or
106
+ remote mounted filesystem. Before a multi-day run, complete a full-load soak,
107
+ stop the service during pretraining, and verify the journal reports a resumed
108
+ checkpoint after restart.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuned-tensor/cli",
3
- "version": "0.13.1-beta.5",
3
+ "version": "0.13.1-beta.7",
4
4
  "description": "Local TT agent and CUDA fine-tuning CLI for Tuned Tensor workflows",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,235 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import hashlib
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import shutil
9
+ import tempfile
10
+ from typing import Any
11
+
12
+ import torch
13
+ from safetensors.torch import load_file, save_file
14
+
15
+ from common import ensure_private_directory, write_json
16
+
17
+
18
+ def _sha256(path: Path) -> str:
19
+ digest = hashlib.sha256()
20
+ with path.open("rb") as source:
21
+ while chunk := source.read(1_048_576):
22
+ digest.update(chunk)
23
+ return digest.hexdigest()
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ResumeState:
28
+ step: int
29
+ samples_consumed: int
30
+ tokens_seen: int
31
+ last_loss: float
32
+ path: Path
33
+
34
+
35
+ class CheckpointManager:
36
+ def __init__(
37
+ self,
38
+ output_dir: str | Path,
39
+ config_fingerprint: str,
40
+ *,
41
+ keep: int = 3,
42
+ backup_dir: str | Path | None = None,
43
+ ) -> None:
44
+ self.root = ensure_private_directory(Path(output_dir) / "checkpoints").resolve()
45
+ self.config_fingerprint = config_fingerprint
46
+ self.keep = max(1, int(keep))
47
+ self.backup_dir = Path(backup_dir).expanduser().resolve() if backup_dir else None
48
+ if self.backup_dir is not None and (
49
+ self.backup_dir == self.root or self.root in self.backup_dir.parents
50
+ ):
51
+ raise ValueError("checkpoint_backup_dir must be outside the primary checkpoints directory.")
52
+
53
+ @staticmethod
54
+ def _name(step: int) -> str:
55
+ return f"step-{step:09d}"
56
+
57
+ def _candidates(self, root: Path | None = None) -> list[Path]:
58
+ candidate_root = root or self.root
59
+ return sorted(
60
+ (path for path in candidate_root.glob("step-*") if path.is_dir()),
61
+ key=lambda path: path.name,
62
+ reverse=True,
63
+ )
64
+
65
+ def _resume_candidates(self) -> list[Path]:
66
+ ranked = [(path, 1) for path in self._candidates()]
67
+ if self.backup_dir is not None and self.backup_dir.is_dir():
68
+ ranked.extend((path, 0) for path in self._candidates(self.backup_dir))
69
+ return [
70
+ path
71
+ for path, _priority in sorted(
72
+ ranked,
73
+ key=lambda candidate: (candidate[0].name, candidate[1]),
74
+ reverse=True,
75
+ )
76
+ ]
77
+
78
+ def latest(self) -> Path | None:
79
+ mismatched = False
80
+ corrupt = False
81
+ for path in self._resume_candidates():
82
+ try:
83
+ metadata = json.loads((path / "metadata.json").read_text(encoding="utf-8"))
84
+ except (OSError, json.JSONDecodeError):
85
+ corrupt = True
86
+ continue
87
+ if not isinstance(metadata, dict) or metadata.get("complete") is not True:
88
+ corrupt = True
89
+ continue
90
+ if metadata.get("config_fingerprint") != self.config_fingerprint:
91
+ mismatched = True
92
+ continue
93
+ model_path = path / "model.safetensors"
94
+ state_path = path / "training-state.pt"
95
+ if not model_path.is_file() or not state_path.is_file():
96
+ corrupt = True
97
+ continue
98
+ files = metadata.get("files")
99
+ if not isinstance(files, dict):
100
+ corrupt = True
101
+ continue
102
+ valid = True
103
+ for name, candidate in [
104
+ ("model.safetensors", model_path),
105
+ ("training-state.pt", state_path),
106
+ ]:
107
+ expected = files.get(name)
108
+ if (
109
+ not isinstance(expected, dict)
110
+ or candidate.stat().st_size != expected.get("size")
111
+ or _sha256(candidate) != expected.get("sha256")
112
+ ):
113
+ valid = False
114
+ break
115
+ if valid:
116
+ return path
117
+ corrupt = True
118
+ if mismatched:
119
+ raise ValueError("Existing foundation checkpoints do not match the current training configuration.")
120
+ if corrupt:
121
+ raise ValueError("No intact foundation checkpoint remains for the current training configuration.")
122
+ return None
123
+
124
+ def load(
125
+ self,
126
+ model: torch.nn.Module,
127
+ optimizer: torch.optim.Optimizer,
128
+ scheduler: torch.optim.lr_scheduler.LRScheduler,
129
+ device: torch.device,
130
+ ) -> ResumeState | None:
131
+ path = self.latest()
132
+ if path is None:
133
+ return None
134
+ model.load_state_dict(load_file(str(path / "model.safetensors"), device=str(device)))
135
+ state = torch.load(path / "training-state.pt", map_location=device, weights_only=True)
136
+ optimizer.load_state_dict(state["optimizer"])
137
+ scheduler.load_state_dict(state["scheduler"])
138
+ torch.set_rng_state(state["cpu_rng"].cpu())
139
+ if device.type == "cuda" and state.get("cuda_rng"):
140
+ torch.cuda.set_rng_state_all([value.cpu() for value in state["cuda_rng"]])
141
+ return ResumeState(
142
+ step=int(state["step"]),
143
+ samples_consumed=int(state["samples_consumed"]),
144
+ tokens_seen=int(state["tokens_seen"]),
145
+ last_loss=float(state.get("last_loss", 0.0)),
146
+ path=path,
147
+ )
148
+
149
+ def save(
150
+ self,
151
+ model: torch.nn.Module,
152
+ optimizer: torch.optim.Optimizer,
153
+ scheduler: torch.optim.lr_scheduler.LRScheduler,
154
+ *,
155
+ step: int,
156
+ samples_consumed: int,
157
+ tokens_seen: int,
158
+ last_loss: float,
159
+ reason: str,
160
+ ) -> Path:
161
+ name = self._name(step)
162
+ final = self.root / name
163
+ if final.exists() and self.latest() == final:
164
+ self._backup(final)
165
+ return final
166
+ temporary = Path(tempfile.mkdtemp(prefix=f".{name}.", dir=self.root))
167
+ temporary.chmod(0o700)
168
+ try:
169
+ tensors = {key: value.detach().cpu().contiguous() for key, value in model.state_dict().items()}
170
+ model_path = temporary / "model.safetensors"
171
+ save_file(tensors, str(model_path))
172
+ model_path.chmod(0o600)
173
+ state_path = temporary / "training-state.pt"
174
+ torch.save({
175
+ "step": int(step),
176
+ "samples_consumed": int(samples_consumed),
177
+ "tokens_seen": int(tokens_seen),
178
+ "last_loss": float(last_loss),
179
+ "optimizer": optimizer.state_dict(),
180
+ "scheduler": scheduler.state_dict(),
181
+ "cpu_rng": torch.get_rng_state(),
182
+ "cuda_rng": torch.cuda.get_rng_state_all() if torch.cuda.is_available() else [],
183
+ }, state_path)
184
+ state_path.chmod(0o600)
185
+ write_json(temporary / "metadata.json", {
186
+ "complete": True,
187
+ "config_fingerprint": self.config_fingerprint,
188
+ "step": int(step),
189
+ "samples_consumed": int(samples_consumed),
190
+ "tokens_seen": int(tokens_seen),
191
+ "reason": reason,
192
+ "files": {
193
+ "model.safetensors": {
194
+ "size": model_path.stat().st_size,
195
+ "sha256": _sha256(model_path),
196
+ },
197
+ "training-state.pt": {
198
+ "size": state_path.stat().st_size,
199
+ "sha256": _sha256(state_path),
200
+ },
201
+ },
202
+ })
203
+ if final.exists():
204
+ shutil.rmtree(final)
205
+ os.replace(temporary, final)
206
+ self._prune()
207
+ self._backup(final)
208
+ return final
209
+ finally:
210
+ if temporary.exists():
211
+ shutil.rmtree(temporary, ignore_errors=True)
212
+
213
+ def _prune(self) -> None:
214
+ for stale in self._candidates()[self.keep:]:
215
+ shutil.rmtree(stale)
216
+
217
+ def _backup(self, checkpoint: Path) -> None:
218
+ if self.backup_dir is None:
219
+ return
220
+ ensure_private_directory(self.backup_dir)
221
+ destination = self.backup_dir / checkpoint.name
222
+ temporary = self.backup_dir / f".{checkpoint.name}.{os.getpid()}.tmp"
223
+ if temporary.exists():
224
+ shutil.rmtree(temporary)
225
+ shutil.copytree(checkpoint, temporary)
226
+ if destination.exists():
227
+ shutil.rmtree(destination)
228
+ os.replace(temporary, destination)
229
+ retained = sorted(
230
+ (path for path in self.backup_dir.glob("step-*") if path.is_dir()),
231
+ key=lambda path: path.name,
232
+ reverse=True,
233
+ )
234
+ for stale in retained[self.keep:]:
235
+ shutil.rmtree(stale)
@@ -2,7 +2,9 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import json
5
+ import os
5
6
  from pathlib import Path
7
+ import tempfile
6
8
  from typing import Any
7
9
 
8
10
 
@@ -29,9 +31,35 @@ def load_config(argv: list[str] | None = None) -> dict[str, Any]:
29
31
  def write_json(path: str | Path, value: Any) -> None:
30
32
  destination = Path(path)
31
33
  ensure_private_directory(destination.parent)
32
- with destination.open("w", encoding="utf-8") as output:
34
+ descriptor, temporary_name = tempfile.mkstemp(
35
+ dir=destination.parent,
36
+ prefix=f".{destination.name}.",
37
+ suffix=".tmp",
38
+ )
39
+ temporary = Path(temporary_name)
40
+ try:
41
+ with os.fdopen(descriptor, "w", encoding="utf-8") as output:
42
+ os.fchmod(output.fileno(), 0o600)
43
+ output.write(json.dumps(value, indent=2) + "\n")
44
+ output.flush()
45
+ os.fsync(output.fileno())
46
+ os.replace(temporary, destination)
33
47
  make_private_file(destination)
34
- output.write(json.dumps(value, indent=2) + "\n")
48
+ finally:
49
+ temporary.unlink(missing_ok=True)
50
+
51
+
52
+ def append_jsonl(path: str | Path, value: Any) -> None:
53
+ destination = Path(path)
54
+ ensure_private_directory(destination.parent)
55
+ descriptor = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
56
+ try:
57
+ payload = (json.dumps(value, separators=(",", ":")) + "\n").encode("utf-8")
58
+ os.write(descriptor, payload)
59
+ os.fsync(descriptor)
60
+ finally:
61
+ os.close(descriptor)
62
+ make_private_file(destination)
35
63
 
36
64
 
37
65
  def require_cuda(action: str) -> None:
@@ -1,8 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import hashlib
4
+ import json
5
+ import os
6
+ from pathlib import Path
4
7
  import re
5
- from typing import Any
8
+ from typing import Any, Iterable, Iterator
6
9
 
7
10
  from tokenizers import Tokenizer
8
11
  from tokenizers import models as tokenizer_models
@@ -18,7 +21,7 @@ SPECIAL_TOKENS = [UNK, PAD, SYSTEM, USER, ASSISTANT, END]
18
21
  IGNORE_INDEX = -100
19
22
 
20
23
 
21
- def train_tokenizer(corpus: str, vocab_size: int) -> Tokenizer:
24
+ def train_tokenizer(corpus: str | Iterable[str], vocab_size: int) -> Tokenizer:
22
25
  tokenizer = Tokenizer(tokenizer_models.BPE(unk_token=UNK))
23
26
  tokenizer.pre_tokenizer = pre_tokenizers.Whitespace()
24
27
  trainer = trainers.BpeTrainer(
@@ -26,10 +29,100 @@ def train_tokenizer(corpus: str, vocab_size: int) -> Tokenizer:
26
29
  special_tokens=SPECIAL_TOKENS,
27
30
  min_frequency=1,
28
31
  )
29
- tokenizer.train_from_iterator([corpus], trainer=trainer)
32
+ tokenizer.train_from_iterator([corpus] if isinstance(corpus, str) else corpus, trainer=trainer)
30
33
  return tokenizer
31
34
 
32
35
 
36
+ def corpus_files(path: str | Path) -> list[Path]:
37
+ unresolved = Path(path).expanduser()
38
+ if unresolved.is_symlink():
39
+ raise ValueError(f"Foundation corpus must not be a symbolic link: {unresolved}")
40
+ source = unresolved.resolve()
41
+ if source.is_file():
42
+ files = [source]
43
+ elif source.is_dir():
44
+ files = sorted(
45
+ candidate for candidate in source.rglob("*")
46
+ if candidate.is_file() and candidate.suffix.lower() in {".txt", ".jsonl"}
47
+ )
48
+ else:
49
+ raise ValueError(f"Foundation corpus path does not exist: {source}")
50
+ if not files:
51
+ raise ValueError(f"Foundation corpus has no .txt or .jsonl files: {source}")
52
+ for candidate in files:
53
+ if candidate.is_symlink():
54
+ raise ValueError(f"Foundation corpus must not contain symbolic links: {candidate}")
55
+ return files
56
+
57
+
58
+ def corpus_manifest(path: str | Path) -> list[dict[str, int | str]]:
59
+ manifest: list[dict[str, int | str]] = []
60
+ for candidate in corpus_files(path):
61
+ info = candidate.stat()
62
+ manifest.append({
63
+ "path": str(candidate),
64
+ "size": int(info.st_size),
65
+ "mtime_ns": int(info.st_mtime_ns),
66
+ })
67
+ return manifest
68
+
69
+
70
+ def iter_corpus_documents(path: str | Path, max_chars: int | None = None) -> Iterator[str]:
71
+ remaining = max_chars if max_chars and max_chars > 0 else None
72
+ for candidate in corpus_files(path):
73
+ if remaining is not None and remaining <= 0:
74
+ return
75
+ if candidate.suffix.lower() == ".jsonl":
76
+ with candidate.open("r", encoding="utf-8") as source:
77
+ for line_number, line in enumerate(source, start=1):
78
+ if remaining is not None and remaining <= 0:
79
+ return
80
+ if not line.strip():
81
+ continue
82
+ try:
83
+ row = json.loads(line)
84
+ except json.JSONDecodeError as error:
85
+ raise ValueError(f"Invalid JSONL at {candidate}:{line_number}: {error.msg}") from error
86
+ if not isinstance(row, dict) or not isinstance(row.get("text"), str):
87
+ raise ValueError(f'Foundation JSONL rows require a string "text" field: {candidate}:{line_number}')
88
+ text = row["text"]
89
+ if remaining is not None:
90
+ text = text[:remaining]
91
+ remaining -= len(text)
92
+ if text:
93
+ yield text
94
+ else:
95
+ with candidate.open("r", encoding="utf-8") as source:
96
+ while True:
97
+ requested = min(1_048_576, remaining) if remaining is not None else 1_048_576
98
+ if requested <= 0:
99
+ return
100
+ text = source.read(requested)
101
+ if not text:
102
+ break
103
+ if remaining is not None:
104
+ remaining -= len(text)
105
+ yield text
106
+
107
+
108
+ def instruction_corpus_documents(system_prompt: str, documents: Iterable[str]) -> Iterator[str]:
109
+ instruction = system_prompt.strip()
110
+ if instruction:
111
+ yield instruction
112
+ yield from documents
113
+
114
+
115
+ def corpus_documents_hash(documents: Iterable[str]) -> tuple[str, int]:
116
+ digest = hashlib.sha256()
117
+ chars = 0
118
+ for document in documents:
119
+ encoded = document.encode("utf-8")
120
+ digest.update(len(encoded).to_bytes(8, "little"))
121
+ digest.update(encoded)
122
+ chars += len(document)
123
+ return digest.hexdigest(), chars
124
+
125
+
33
126
  def smoke_corpus(examples: list[dict[str, str]], max_chars: int, extra: str = "") -> str:
34
127
  chunks = [extra, *[example.get("input", "") for example in examples], *[example.get("output", "") for example in examples]]
35
128
  seed = "\n".join(part for part in chunks if part).strip() or "the quick brown fox jumps over the lazy dog"
@@ -55,6 +148,52 @@ def encode_ids(tokenizer: Tokenizer, text: str) -> list[int]:
55
148
  return list(tokenizer.encode(text).ids)
56
149
 
57
150
 
151
+ def encode_prompt_ids(
152
+ tokenizer: Tokenizer,
153
+ system_prompt: str,
154
+ user: str,
155
+ max_tokens: int,
156
+ ) -> list[int]:
157
+ """Keep the complete spec instruction and trim only user content."""
158
+ full_ids = encode_ids(tokenizer, format_prompt(system_prompt, user))
159
+ if len(full_ids) <= max_tokens:
160
+ return full_ids
161
+ system = system_prompt.strip() or "You are a helpful assistant."
162
+ prefix_ids = encode_ids(tokenizer, f"{SYSTEM}{system}{END}{USER}")
163
+ suffix_ids = encode_ids(tokenizer, f"{END}{ASSISTANT}")
164
+ if len(prefix_ids) + len(suffix_ids) > max_tokens:
165
+ raise ValueError(
166
+ "Compiled spec instruction exceeds the foundation model context; "
167
+ "increase foundation.sequence_length or shorten the spec instruction."
168
+ )
169
+ user_ids = encode_ids(tokenizer, user)
170
+ user_budget = max_tokens - len(prefix_ids) - len(suffix_ids)
171
+ retained_user_ids = user_ids[-user_budget:] if user_budget > 0 else []
172
+ return prefix_ids + retained_user_ids + suffix_ids
173
+
174
+
175
+ def encode_generation_prompt(
176
+ tokenizer: Tokenizer,
177
+ system_prompt: str,
178
+ user: str,
179
+ context_length: int,
180
+ requested_completion_tokens: int,
181
+ ) -> tuple[list[int], int]:
182
+ """Reserve completion space without rejecting an instruction that can fit."""
183
+ completion_tokens = min(requested_completion_tokens, max(1, context_length // 2))
184
+ try:
185
+ prompt_ids = encode_prompt_ids(
186
+ tokenizer,
187
+ system_prompt,
188
+ user,
189
+ context_length - completion_tokens,
190
+ )
191
+ except ValueError:
192
+ prompt_ids = encode_prompt_ids(tokenizer, system_prompt, user, context_length - 1)
193
+ completion_tokens = context_length - len(prompt_ids)
194
+ return prompt_ids, completion_tokens
195
+
196
+
58
197
  def decode_ids(tokenizer: Tokenizer, ids: list[int]) -> str:
59
198
  return tokenizer.decode(ids, skip_special_tokens=False)
60
199
 
@@ -82,9 +221,18 @@ def encode_sft_example(
82
221
  full_ids = encode_ids(tokenizer, format_chat(system_prompt, user, assistant))
83
222
  model_tokens = sequence_length + 1
84
223
  if len(full_ids) > model_tokens:
85
- overflow = len(full_ids) - model_tokens
86
- prefix_ids = prefix_ids[overflow:] if overflow < len(prefix_ids) else []
87
- full_ids = full_ids[-model_tokens:]
224
+ assistant_ids = encode_ids(tokenizer, f"{assistant}{END}")
225
+ reserved_assistant_tokens = min(2, len(assistant_ids))
226
+ prefix_ids = encode_prompt_ids(
227
+ tokenizer,
228
+ system_prompt,
229
+ user,
230
+ model_tokens - reserved_assistant_tokens,
231
+ )
232
+ assistant_budget = model_tokens - len(prefix_ids)
233
+ if len(assistant_ids) > assistant_budget:
234
+ assistant_ids = assistant_ids[:assistant_budget - 1] + assistant_ids[-1:]
235
+ full_ids = prefix_ids + assistant_ids
88
236
  input_ids = pad_or_trim(full_ids[:-1], sequence_length, pad_id)
89
237
  target_ids = full_ids[1:]
90
238
  first_assistant_target = max(0, min(len(prefix_ids) - 1, len(target_ids)))