@tuned-tensor/cli 0.25.0 → 0.26.0

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.
@@ -6,7 +6,7 @@ import {
6
6
  safeName,
7
7
  verifyLocalBaseModel,
8
8
  verifyModelPrefetchCacheReport
9
- } from "./chunk-UCJCWJ6F.js";
9
+ } from "./chunk-4BHDPRYD.js";
10
10
  export {
11
11
  buildModelPrefetchPayload,
12
12
  buildModelSourcePrefetchPayload,
@@ -15,4 +15,4 @@ export {
15
15
  verifyLocalBaseModel,
16
16
  verifyModelPrefetchCacheReport
17
17
  };
18
- //# sourceMappingURL=prefetch-GP3OPUUL.js.map
18
+ //# sourceMappingURL=prefetch-DH365HNN.js.map
@@ -121,6 +121,57 @@ The Python evaluator receives only opaque IDs and prompts. Node joins its
121
121
  predictions to trusted references before scoring. This avoids accidental label
122
122
  leakage across the process boundary.
123
123
 
124
+ Generation uses left-padded batches of four prompts by default. Set
125
+ `evaluation.inference.batchSize` (1–128) in `tunedtensor.json` to tune memory
126
+ use. A CUDA allocation failure halves the batch and retries the unfinished
127
+ examples; failure at one example is reported instead of retrying forever.
128
+ Batch latency is the time each request waits for its whole batch, including
129
+ decoding; it is not latency divided by batch size. Batched greedy generation
130
+ can differ from single-prompt generation because of floating-point arithmetic.
131
+
132
+ On integrated CUDA devices such as GB10, training and evaluation first open
133
+ the weights on CPU, then transfer them to CUDA. This avoids retaining host
134
+ checkpoint copies alongside the full GPU allocation in shared system memory.
135
+ Generation and training still execute on CUDA with the same configured dtype.
136
+
137
+ Ordinary run output shows loading/generation, completed examples, throughput,
138
+ ETA and a heartbeat every 30 seconds. It warns if the estimated remaining work
139
+ exceeds `evaluation.timeoutMs`; that limit remains a hard wall-clock deadline
140
+ (30 minutes by default), including model loading. An ETA is an estimate, not
141
+ a deadline extension. `--verbose` additionally streams the raw process logs.
142
+
143
+ Each completed batch atomically saves a private prediction checkpoint. An
144
+ incomplete checkpoint is never scored as a completed evaluation. With the
145
+ default `evaluation.baselineCache: true`, rerunning the same baseline inputs
146
+ in a new run automatically continues the checkpoint in the local store's
147
+ `cache/baseline-evals/` directory. Changing only the timeout preserves reuse.
148
+ Changed prompts, model revision/fingerprint, generation settings, batch size
149
+ or package version invalidate it. Local base-model paths without a verified
150
+ fingerprint do not resume. Adapter contents are hashed before candidate
151
+ checkpoint reuse, so retraining at the same path cannot reuse old answers.
152
+ Per-run checkpoints live in `inference-checkpoints/` in the run directory;
153
+ retrying the same saved run request reuses them. A new training run has a new
154
+ candidate identity. Completed baseline reports retain their existing cache.
155
+ Checkpoints contain generated text; remove them when no longer needed.
156
+
157
+ For a smoke run, explicitly set a small deterministic sample, for example:
158
+
159
+ ```json
160
+ "evaluation": {
161
+ "maxExamples": 16,
162
+ "sampleSeed": 42,
163
+ "timeoutMs": 3600000,
164
+ "inference": { "batchSize": 4 }
165
+ }
166
+ ```
167
+
168
+ This limits evaluation, not training. Baseline and candidate use the same
169
+ sample. Remove `maxExamples` for full held-out evaluation and choose a timeout
170
+ appropriate to the measured throughput. TT never silently reduces the
171
+ evaluation set to meet a deadline. A smoke sample is pipeline validation,
172
+ not evidence of overall model quality. Exact text matching is also not a
173
+ coding correctness test; the scoring contract is unchanged here.
174
+
124
175
  ## Storage and integrity
125
176
 
126
177
  Canonical JSON and JSONL files are the state store; there is no database or
@@ -169,6 +169,13 @@ evaluation (`evaluation.inference.device: "cpu"`) stays on the laptop.
169
169
  Remove `gpu` to execute everything locally. `--dry-run` or `dryRun: true`
170
170
  does not connect to AWS or copy files.
171
171
 
172
+ To keep using the terminal or the TT agent during a long run, start it in the
173
+ background instead: `tt pipeline start --spec tunedtensor.json --reason "..."`,
174
+ or approve a background run the agent proposes after the dry-run. Check it with
175
+ `tt pipeline status <id>` and stop it with `tt pipeline cancel <id> --reason "..."`.
176
+ The laptop still orchestrates the run, so keep it awake. See
177
+ [background runs](background-runs.md).
178
+
172
179
  ## 5. Inspect results and finish
173
180
 
174
181
  Wait for a successful completion message. Adapter runs return a run ID and a
@@ -228,6 +235,14 @@ Foundation pretraining can resume from returned checkpoints using its existing
228
235
  so checkpoint identities still match. Adapter training keeps its existing
229
236
  restart behavior.
230
237
 
238
+ Adapter evaluation runs in configurable batches (`evaluation.inference.batchSize`,
239
+ default 4), reducing the batch after a CUDA out-of-memory error. Completed
240
+ prediction checkpoints return with outputs on success, cancellation or timeout
241
+ while SSH remains reachable. Matching baseline inputs reuse the returned
242
+ checkpoint on the next run when `evaluation.baselineCache` is enabled. As with
243
+ foundation checkpoints, nothing is continuously copied to the laptop during
244
+ generation. See [evaluation and smoke runs](architecture.md#evaluation).
245
+
231
246
  You provide capacity and pay AWS directly. TT does not create, start, stop,
232
247
  terminate or resize instances, request quotas, choose fallback instances or
233
248
  fall back to another account. An AWS profile alone cannot create capacity.
@@ -0,0 +1,77 @@
1
+ # Background runs
2
+
3
+ A background run is a real `tt pipeline run` that keeps going after the shell or
4
+ conversation that started it moves on. Start one from the TT agent (after an
5
+ approved dry-run) or directly:
6
+
7
+ ```bash
8
+ tt pipeline run --spec tunedtensor.json --dry-run
9
+ tt pipeline start --spec tunedtensor.json --reason "Check the new negation examples"
10
+ tt pipeline list
11
+ tt pipeline status <id>
12
+ tt pipeline logs <id>
13
+ tt pipeline cancel <id> --reason "Started on the wrong dataset"
14
+ ```
15
+
16
+ IDs accept a unique prefix. `--json` prints machine-readable output for every
17
+ command.
18
+
19
+ ## From the agent
20
+
21
+ The agent can prepare, but never start, a run. The flow is:
22
+
23
+ 1. `prepare_pipeline_run`: you approve a dry-run preview of the saved spec.
24
+ 2. `prepare_background_run`: the agent proposes a real run of **the same spec
25
+ bytes** with a short reason. The proposal shows the plan, the target (laptop
26
+ or AWS instance), active runs, the parallel-run limit and current CPU, memory
27
+ and GPU usage. It is refused if no matching dry-run completed in the
28
+ conversation, if the spec changed, or if the parallel limit is reached.
29
+ 3. On approval, tt revalidates the spec, writes the run record and starts a
30
+ detached supervisor. The conversation continues immediately.
31
+
32
+ While a run is active, the agent answers questions with read-only tools:
33
+ `list_background_runs`, `get_background_run` (status, metrics and the recent
34
+ log) and `get_system_usage` (CPU, memory, per-GPU load and active runs). It can
35
+ only stop a run through `prepare_cancel_background_run`, which you approve. When
36
+ a run started from a conversation finishes, the agent is told its outcome on your
37
+ next message; it cannot poll in the background.
38
+
39
+ ## Isolation
40
+
41
+ The run executes the sealed spec captured at approval, not the live
42
+ `tunedtensor.json`, so later spec edits in the conversation cannot change it.
43
+ The snapshot sits beside the spec while the run is active (as
44
+ `.tunedtensor.json.tt-agent-<id>.json`), so relative paths, output directories
45
+ and `tunedtensor.lock.json` behave exactly as for a direct run. It is removed
46
+ when the run ends. The supervisor runs in its own process group, so closing tt
47
+ or the terminal does not stop the run; use `tt pipeline cancel`.
48
+
49
+ ## Parallel runs
50
+
51
+ By default one background run may be active at a time. Set
52
+ `TT_MAX_PARALLEL_RUNS` (1–8) to allow more. Runs that share a target (the
53
+ laptop GPU, or the same AWS instance) share its GPU memory: check
54
+ `get_system_usage`, `nvidia-smi` or `tt doctor` first. The proposal warns when a
55
+ new run would share a target with an active one. Laptop usage figures do not
56
+ describe a remote AWS GPU.
57
+
58
+ ## Audit record
59
+
60
+ Each run keeps a private directory under `~/.tuned-tensor/background-runs/<id>/`:
61
+
62
+ | File | Contents |
63
+ | --- | --- |
64
+ | `record.json` | Reason; requester (OS user, host, `agent` or `cli`, conversation, action and dry-run action IDs); approval time; choices (engine, spec path and SHA-256, config, steps, target, base model); resources at launch; process IDs; cancellation reason and requester; outcome (exit code, local run ID, model ID, report path, evaluation metrics, error). |
65
+ | `spec.json` | The sealed spec the run consumed. |
66
+ | `events.jsonl` | Hash-chained lifecycle events: requested, started, cancel requested, finished or lost. `tt pipeline status` verifies the chain. |
67
+ | `output.log` | Progress and process logs. |
68
+ | `result.json` | The pipeline's final JSON result. |
69
+
70
+ Adapter runs also keep their usual record in the local run store; use
71
+ `tt runs audit <run-id>`, `tt runs report <run-id>` and
72
+ `tt runs compare <a> <b>` with the run ID from the outcome. The hash chain makes
73
+ casual edits evident; it is not a signature.
74
+
75
+ If the supervisor disappears (crash, reboot), the run is reported as `lost` the
76
+ next time it is read. Inspect its log and the local run store before starting
77
+ again; a foundation run can be resumed with `tt pipeline run --resume`.
@@ -12,6 +12,11 @@ TT fine-tunes [Laya](https://huggingface.co/convaiinnovations/laya)
12
12
  encoder with a typed decision head. Every stage uses Laya revision
13
13
  `55cf4c4ebb4ebe31b2550e8bdf3bd21b99753851`, the reviewed commit that Laya 0.3.22
14
14
  pins. Training and evaluation run on CPU, Apple MPS or CUDA; no GPU is required.
15
+ The startup panel and `tt hardware` show decision capabilities even without a
16
+ spec. A full hardware check probes the decision runtime separately from the
17
+ adapter runtime. Native Windows training is not yet end-to-end certified;
18
+ Intel macOS and Windows ARM64 have no compatible packaged PyTorch wheel.
19
+ See the [platform guidance](../../README.md) for setup and memory caveats.
15
20
 
16
21
  ## Spec
17
22
 
@@ -28,8 +28,12 @@ not mutate the training runtime.
28
28
  Keep each model paired with its original training tokenizer. Context defaults to
29
29
  the checkpoint limit. Text completions and chat are supported; foundation tool-call
30
30
  parsing and image inputs are not supported.
31
- - Qwen3.5-2B base and a one-step, rank-16 `all-linear` TT-shaped adapter have been
32
- exercised on GB10. The registry's Nemotron and Muse Glimmer architectures have
31
+ - Qwen3.5-2B base and a rank-16 `all-linear` adapter trained for one epoch on
32
+ 10,458 banking-intent examples have been exercised on GB10. On a fixed
33
+ 100-example test sample spanning all 77 labels, serving scored 53/100 for the
34
+ base and 93/100 for the adapter; all adapter predictions matched offline
35
+ evaluation. This checks adapter application, not general coding ability.
36
+ The registry's Nemotron and Muse Glimmer architectures have
33
37
  upstream support, but their exact adapters/hardware combinations are **not
34
38
  certified by that Qwen smoke test**. No 70B model was tested here.
35
39
 
@@ -48,6 +52,11 @@ never enables runtime adapter upload/loading. There is no merge/export step
48
52
  and no modification of the verified weights. `tt serve base` starts base-only.
49
53
  `active` still fails when no adapter is activated.
50
54
 
55
+ Qwen text-model adapters use a temporary copy with layer names mapped to vLLM's
56
+ conditional-model layout. Tensor values, adapter settings and the verified
57
+ artifact stay unchanged. This prevents vLLM from accepting an adapter whose
58
+ layer names do not match the loaded model and silently serving base behavior.
59
+
51
60
  Artifact integrity, certified base revisions, and stored behavior-prompt
52
61
  fingerprints retain their verification. A stored adapter's behavior prompt is
53
62
  applied to both model IDs for comparison. `tt serve base` without explicit
@@ -24,11 +24,17 @@ Inside the TT shell, in your project directory:
24
24
 
25
25
  `/spec` shows a readable overview: name, engine, base model and example count,
26
26
  then highlighted sections for behavior (system prompt, guidelines, constraints),
27
- examples, training settings, evaluation, runtime and pipeline, followed by
27
+ the `dataset_prebuilt` data source when set, examples, training settings, evaluation, runtime and pipeline, followed by
28
28
  validation results. `/spec <section>` shows one section in full; sections are
29
- `identity`, `behavior`, `examples`, `training`, `evaluation`, `runtime` and
29
+ `identity`, `behavior`, `dataset`, `examples`, `training`, `evaluation`, `runtime` and
30
30
  `pipeline`. `/spec show` prints the complete file with JSON highlighting,
31
31
  validation results and SHA-256.
32
+
33
+ For adapter specs, `dataset_prebuilt` replaces inline examples as training data;
34
+ the examples section labels this explicitly. Foundation and decision specs reject
35
+ `dataset_prebuilt`; remove it to validate the spec. Foundation corpus settings
36
+ are shown under `/spec training`.
37
+
32
38
  `/spec diff` shows pending spec edits in the current conversation; otherwise it
33
39
  shows the latest approved edit, or external changes since that revision.
34
40
  `/spec history` lists up to 50 recent revision records. These commands require
@@ -163,3 +169,67 @@ reproducibility; these are outputs, not additional user-authored configuration.
163
169
  Agent `/approve` for a pipeline still runs a preview only. Real training uses
164
170
  the direct command above. The spec is the behavior recipe; approval of a spec
165
171
  edit is not approval to launch training.
172
+
173
+ ## Splitting an unsplit dataset
174
+
175
+ Keep the reusable master dataset as one chat JSONL file. For a downloaded
176
+ Hugging Face chat dataset, `inspect_local_dataset` returns a pinned `split_source`
177
+ (`hf://datasets/org/repo@<commit>/data.jsonl`). The inspection and split tools
178
+ accept that reference directly, verifying the recorded checksum without copying
179
+ or downloading the master again. Outputs still go into a new workspace directory.
180
+ The CLI accepts the same reference in place of `data.jsonl` below.
181
+
182
+ Ask the agent:
183
+
184
+ > Split data.jsonl into 80% training and 20% validation, using seed 123.
185
+ > Balance metadata.category, save to split-123, and use the files in my spec.
186
+
187
+ The agent validates the file, previews counts, and asks for approval to create
188
+ new split files. It then reads the spec and proposes a separate
189
+ `dataset_prebuilt` edit using the recorded output paths. Training remains a
190
+ separate step: the agent can preview a dry-run; actual training uses
191
+ `tt pipeline run --spec tunedtensor.json`.
192
+
193
+ The same operation is available without an agent or account:
194
+
195
+ ```sh
196
+ tt datasets inspect data.jsonl
197
+ tt datasets split data.jsonl --output split-123 \
198
+ --train 0.8 --validation 0.2 --test 0 --seed 123 \
199
+ --stratify-by metadata.category --dry-run
200
+ # Remove --dry-run to create the reviewed files.
201
+ ```
202
+
203
+ Defaults are 80/10/10 with seed `42`. `--method ordered` assigns groups by source
204
+ order instead of seeded random membership. Records retain their source order
205
+ within each output; the seed controls membership, not training batch order.
206
+ `--group-by group_id` keeps related examples together; without it, normalized
207
+ user prompts define groups. Repeated prompts cannot cross splits, including
208
+ when using a custom grouping field. Groups containing repeated prompts stay in
209
+ training: TT requires unique prompts within each evaluation split. The preview
210
+ and manifest report these rows as `training_only_rows`. Ratios are approximate at group boundaries;
211
+ requested nonzero splits must contain examples. `--stratify-by` accepts a scalar
212
+ field (including dotted metadata paths), and each group must belong to one
213
+ stratum. This is not a near-duplicate detector or a time-series split engine.
214
+
215
+ Inputs must use an optional system message, one user message, and one assistant
216
+ answer per row. Metadata is preserved. During adapter training, rows without a
217
+ system message receive the current spec’s compiled system prompt, guidelines and
218
+ constraints in the run artifact. An explicit system message must match those
219
+ instructions; it is never silently replaced. Master and split files stay unchanged. Paths must be inside the current
220
+ workspace without symlinks; the output directory must be new, and its parent
221
+ must exist. The source and spec remain unchanged by the splitter. A manifest
222
+ records the source checksum, options, counts, and relative `dataset_prebuilt`
223
+ paths. A prepared agent action refuses a source changed since review.
224
+
225
+ Source split labels are metadata only. Random/ordered splitting does not enforce
226
+ original benchmark holdouts; preserve those holdouts separately when reporting
227
+ results against the original benchmark. Never shuffle a published test set into
228
+ training and still describe the result as that benchmark's evaluation.
229
+
230
+ Inspection and splitting stream source records, but the existing adapter training
231
+ loader still materializes complete split files and compiled JSONL in memory.
232
+ Splitting a multi-gigabyte master does not remove that training-loader limit.
233
+ Do not treat a successful split preview as proof that an arbitrarily large split
234
+ can train; streaming training ingestion or bounded dataset selection is separate
235
+ work. Use `tt validate` on the saved spec to validate the actual training inputs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuned-tensor/cli",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "Local TT agent and CUDA fine-tuning CLI for Tuned Tensor workflows",
5
5
  "repository": {
6
6
  "type": "git",
@@ -8,7 +8,8 @@
8
8
  },
9
9
  "type": "module",
10
10
  "bin": {
11
- "tt": "dist/index.js"
11
+ "tt": "dist/index.js",
12
+ "TT": "dist/index.js"
12
13
  },
13
14
  "scripts": {
14
15
  "build": "tsup",
@@ -1,12 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import hashlib
4
5
  import json
5
6
  import os
6
7
  import tarfile
7
8
  import time
8
9
  from pathlib import Path
9
- from tempfile import TemporaryDirectory
10
+ from tempfile import NamedTemporaryFile, TemporaryDirectory
10
11
  from typing import Any
11
12
 
12
13
  from model_contract import (
@@ -15,6 +16,7 @@ from model_contract import (
15
16
  assert_certified_base_model_revision,
16
17
  assert_certified_model_config,
17
18
  chat_template_kwargs,
19
+ cuda_model_load_device_map,
18
20
  uncertified_load_kwargs,
19
21
  )
20
22
 
@@ -186,6 +188,8 @@ def load_text_model(
186
188
  raise ValueError(f"{base_model} tokenizer has no EOS token")
187
189
  if tokenizer.pad_token_id is None:
188
190
  tokenizer.pad_token = tokenizer.eos_token
191
+ # Decoder-only generation must end every padded prompt on a real token.
192
+ tokenizer.padding_side = "left"
189
193
 
190
194
  dtype = None
191
195
  if device == "cuda":
@@ -195,7 +199,7 @@ def load_text_model(
195
199
  if dtype is not None:
196
200
  model_kwargs["dtype"] = dtype
197
201
  if device == "cuda":
198
- model_kwargs["device_map"] = {"": torch.cuda.current_device()}
202
+ model_kwargs["device_map"] = cuda_model_load_device_map(torch)
199
203
 
200
204
  model_class = (
201
205
  AutoModelForImageTextToText
@@ -206,7 +210,7 @@ def load_text_model(
206
210
  assert_certified_model_config(model.config, expected_model_id=base_model)
207
211
  if adapter_path:
208
212
  model = PeftModel.from_pretrained(model, adapter_path)
209
- if device != "cuda":
213
+ if device != "cuda" or model_kwargs["device_map"] is None:
210
214
  model = model.to(device)
211
215
  model.eval()
212
216
  return model, tokenizer, device
@@ -223,19 +227,19 @@ def sampling_kwargs(generation: dict[str, Any]) -> dict[str, Any]:
223
227
  }
224
228
 
225
229
 
226
- def generate_text_one(
230
+ def generate_text_batch(
227
231
  model: Any,
228
232
  tokenizer: Any,
229
233
  system: str,
230
- example: dict[str, Any],
234
+ examples: list[dict[str, Any]],
231
235
  generation: dict[str, Any],
232
236
  base_model: str = CERTIFIED_BASE_MODEL,
233
- ) -> dict[str, Any]:
234
- if example.get("input_assets"):
237
+ ) -> list[dict[str, Any]]:
238
+ if any(example.get("input_assets") for example in examples):
235
239
  raise ValueError("The bundled SFT evaluator is text-only and does not accept input_assets")
236
- prompt = str(example["input"])
237
- formatted = format_prompt(tokenizer, system, prompt, base_model)
238
- inputs = tokenizer(formatted, return_tensors="pt")
240
+ formatted = [format_prompt(tokenizer, system, str(example["input"]), base_model)
241
+ for example in examples]
242
+ inputs = tokenizer(formatted, return_tensors="pt", padding=True, add_special_tokens=False)
239
243
  target_device = next(model.parameters()).device
240
244
  inputs = {key: value.to(target_device) for key, value in inputs.items()}
241
245
  started = time.perf_counter()
@@ -243,24 +247,126 @@ def generate_text_one(
243
247
  generated = model.generate(
244
248
  **inputs,
245
249
  max_new_tokens=int(generation.get("max_new_tokens", 256)),
246
- pad_token_id=tokenizer.eos_token_id,
250
+ pad_token_id=tokenizer.pad_token_id,
247
251
  **sampling_kwargs(generation),
248
252
  )
249
- latency_ms = max(0, round((time.perf_counter() - started) * 1000))
250
253
  input_length = int(inputs["input_ids"].shape[-1])
251
- output_tokens = generated[0][input_length:]
252
- actual = tokenizer.decode(output_tokens, skip_special_tokens=True).strip()
253
- return {
254
+ # Slice at the padded width, not each prompt's unpadded length.
255
+ actuals = [tokenizer.decode(row[input_length:], skip_special_tokens=True).strip()
256
+ for row in generated]
257
+ # Decoding synchronizes CUDA before measuring wall latency.
258
+ latency_ms = max(0, round((time.perf_counter() - started) * 1000))
259
+ return [{
254
260
  "id": str(example["id"]),
255
261
  "actual": actual,
262
+ # Each request waits for the whole batch; this is batch wall latency.
256
263
  "latency_ms": latency_ms,
257
- }
264
+ } for example, actual in zip(examples, actuals, strict=True)]
265
+
266
+
267
+ def write_json_atomic(path: Path, value: dict[str, Any]) -> None:
268
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
269
+ temporary: str | None = None
270
+ try:
271
+ with NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent,
272
+ prefix=f".{path.name}.", delete=False) as handle:
273
+ temporary = handle.name
274
+ json.dump(value, handle)
275
+ handle.flush()
276
+ os.fsync(handle.fileno())
277
+ os.replace(temporary, path)
278
+ finally:
279
+ if temporary and os.path.exists(temporary):
280
+ os.unlink(temporary)
281
+
282
+
283
+ def checkpoint_identity(payload: dict[str, Any], adapter_path: str | None) -> str:
284
+ digest = hashlib.sha256(str(payload.get("checkpoint_key", "")).encode())
285
+ # A retrained/replaced adapter at the same path must never reuse predictions.
286
+ if adapter_path:
287
+ root = Path(adapter_path)
288
+ for path in sorted(root.rglob("*")):
289
+ if path.is_file():
290
+ digest.update(str(path.relative_to(root)).encode())
291
+ with path.open("rb") as handle:
292
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
293
+ digest.update(chunk)
294
+ return digest.hexdigest()
295
+
296
+
297
+ def read_checkpoint(path: Path, identity: str, examples: list[dict[str, Any]]) -> list[dict[str, Any]]:
298
+ try:
299
+ value = load_json(path)
300
+ results = value.get("results")
301
+ if value.get("identity") != identity or not isinstance(results, list) or len(results) > len(examples):
302
+ return []
303
+ for example, result in zip(examples, results):
304
+ if (not isinstance(result, dict) or result.get("id") != str(example["id"])
305
+ or not isinstance(result.get("actual"), str)
306
+ or type(result.get("latency_ms")) is not int or result["latency_ms"] < 0):
307
+ return []
308
+ return results
309
+ except (OSError, ValueError):
310
+ # A missing, interrupted or incompatible checkpoint is a fresh evaluation.
311
+ return []
312
+
313
+
314
+ def emit_progress(phase: str, completed: int, total: int, batch_size: int,
315
+ elapsed: float = 0, eta: float | None = None) -> None:
316
+ print("@@tt-evaluation " + json.dumps({
317
+ "phase": phase, "completed": completed, "total": total,
318
+ "batch_size": batch_size, "elapsed_seconds": round(elapsed, 2),
319
+ "eta_seconds": round(eta, 2) if eta is not None else None,
320
+ }), flush=True)
321
+
322
+
323
+ def evaluate_batches(payload: dict[str, Any], adapter_path: str | None,
324
+ checkpoint: Path, model_loader: str) -> list[dict[str, Any]]:
325
+ examples = payload.get("examples", [])
326
+ batch_size = payload.get("batch_size", 4)
327
+ if type(batch_size) is not int or not 1 <= batch_size <= 128:
328
+ raise ValueError("batch_size must be an integer between 1 and 128")
329
+ generation = payload.get("generation", {})
330
+ if not isinstance(generation, dict):
331
+ raise ValueError("generation must be a JSON object")
332
+ identity = checkpoint_identity(payload, adapter_path)
333
+ results = read_checkpoint(checkpoint, identity, examples) if payload.get("checkpoint_key") else []
334
+ resumed = len(results)
335
+ emit_progress("resuming" if resumed else "loading", resumed, len(examples), batch_size)
336
+ if resumed == len(examples):
337
+ return results
338
+ model, tokenizer, device = load_text_model(payload, adapter_path, model_loader)
339
+ started = time.perf_counter()
340
+ emit_progress("generating", resumed, len(examples), batch_size)
341
+ while len(results) < len(examples):
342
+ batch = examples[len(results):len(results) + batch_size]
343
+ predictions = None
344
+ try:
345
+ predictions = generate_text_batch(model, tokenizer, str(payload.get("system", "")),
346
+ batch, generation, str(payload["base_model"]))
347
+ except torch.cuda.OutOfMemoryError:
348
+ if device != "cuda" or len(batch) == 1:
349
+ raise
350
+ # Retry only allocation failures; release the failed call's tensors first.
351
+ batch_size = max(1, len(batch) // 2)
352
+ if predictions is None:
353
+ # The exception's traceback is now released, including failed tensors.
354
+ torch.cuda.empty_cache()
355
+ emit_progress("reducing_batch", len(results), len(examples), batch_size)
356
+ continue
357
+ results.extend(predictions)
358
+ write_json_atomic(checkpoint, {"identity": identity, "results": results})
359
+ elapsed = time.perf_counter() - started
360
+ eta = elapsed / (len(results) - resumed) * (len(examples) - len(results))
361
+ emit_progress("generating", len(results), len(examples), batch_size, elapsed, eta)
362
+ return results
258
363
 
259
364
 
260
365
  def main() -> None:
261
366
  parser = argparse.ArgumentParser()
262
367
  parser.add_argument("--input", required=True)
263
368
  parser.add_argument("--output", required=True)
369
+ parser.add_argument("--checkpoint")
264
370
  args = parser.parse_args()
265
371
 
266
372
  payload = load_json(Path(args.input))
@@ -273,37 +379,24 @@ def main() -> None:
273
379
  "expected causal_lm or image_text_to_text"
274
380
  )
275
381
  configure_hugging_face_cache(payload.get("model_cache"))
382
+ emit_progress("initializing", 0, len(payload.get("examples", [])), payload.get("batch_size", 4))
276
383
  import_runtime_dependencies()
277
384
 
278
385
  with TemporaryDirectory(prefix="tt-local-evaluate-") as tmp_dir:
279
386
  adapter_path = resolve_adapter_path(payload.get("adapter_path"), Path(tmp_dir))
280
- generation = payload.get("generation", {})
281
- if not isinstance(generation, dict):
282
- raise ValueError("generation must be a JSON object")
283
- model, tokenizer, _device = load_text_model(payload, adapter_path, model_loader)
284
- results = [
285
- generate_text_one(
286
- model,
287
- tokenizer,
288
- str(payload.get("system", "")),
289
- example,
290
- generation,
291
- str(payload["base_model"]),
292
- )
293
- for example in payload.get("examples", [])
294
- ]
387
+ checkpoint = Path(args.checkpoint or f"{args.output}.checkpoint.json")
388
+ results = evaluate_batches(payload, adapter_path, checkpoint, model_loader)
295
389
 
296
390
  output = {
297
391
  "provider": "transformers",
298
392
  "model_id": payload.get("model_id"),
299
393
  "base_model": payload.get("base_model"),
300
394
  "adapter_path": payload.get("adapter_path"),
301
- "generation_config": generation,
395
+ "generation_config": payload.get("generation", {}),
302
396
  "results": results,
303
397
  }
304
398
  output_path = Path(args.output)
305
- output_path.parent.mkdir(parents=True, exist_ok=True)
306
- output_path.write_text(json.dumps(output, indent=2), encoding="utf-8")
399
+ write_json_atomic(output_path, output)
307
400
 
308
401
 
309
402
  if __name__ == "__main__":
@@ -20,6 +20,19 @@ CERTIFIED_BASE_MODELS = (CERTIFIED_BASE_MODEL, NEMOTRON_BASE_MODEL, MUSE_GLIMMER
20
20
  # the run pins an immutable commit. Node enforces the same contract.
21
21
  HUGGING_FACE_REPO_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,95}/[A-Za-z0-9][A-Za-z0-9._-]{0,95}")
22
22
  COMMIT_SHA = re.compile(r"[0-9a-fA-F]{40}")
23
+
24
+
25
+ def cuda_model_load_device_map(torch_runtime: Any) -> dict[str, int] | None:
26
+ """Stage on CPU before CUDA transfer when host and GPU share memory.
27
+
28
+ Direct CUDA loading can retain a second host copy until checkpoint readers
29
+ close. CPU-first loading lets those readers close before the CUDA transfer.
30
+ """
31
+ index = torch_runtime.cuda.current_device()
32
+ integrated = torch_runtime.cuda.get_device_properties(index).is_integrated
33
+ return None if integrated else {"": index}
34
+
35
+
23
36
  CERTIFIED_QWEN_TEXT_CONFIG = {
24
37
  "model_type": "qwen3_5_text",
25
38
  "hidden_size": 2048,
@@ -63,6 +63,35 @@ def pin_native_content_format(args: list[str], model_source: str) -> None:
63
63
  args += ["--chat-template-content-format", content_format]
64
64
 
65
65
 
66
+ def prepare_serving_adapter(adapter_path: str, temp_dir: Path) -> str:
67
+ # Transformers trains Qwen's text-only model, but vLLM loads the repository's
68
+ # conditional architecture, even with --language-model-only. Its LoRA mapper
69
+ # expects the language_model prefix; otherwise it silently misses our layers.
70
+ if os.environ.get("TT_BASE_MODEL") != "Qwen/Qwen3.5-2B":
71
+ return adapter_path
72
+ from safetensors import safe_open
73
+ from safetensors.torch import save_file
74
+
75
+ source = Path(adapter_path)
76
+ old = "base_model.model.model.layers."
77
+ new = "base_model.model.model.language_model.layers."
78
+ with safe_open(str(source / "adapter_model.safetensors"), framework="pt", device="cpu") as weights:
79
+ if not any(key.startswith(old) for key in weights.keys()):
80
+ return adapter_path
81
+ tensors = {}
82
+ for key in weights.keys():
83
+ mapped = new + key[len(old):] if key.startswith(old) else key
84
+ if mapped in tensors:
85
+ raise ValueError("Adapter contains conflicting text and conditional layer names.")
86
+ tensors[mapped] = weights.get_tensor(key)
87
+ destination = temp_dir / "serving-adapter"
88
+ destination.mkdir()
89
+ save_file(tensors, str(destination / "adapter_model.safetensors"), metadata=weights.metadata())
90
+ # Keep the verified archive and extracted training layout unchanged.
91
+ (destination / "adapter_config.json").write_bytes((source / "adapter_config.json").read_bytes())
92
+ return str(destination)
93
+
94
+
66
95
  def build_vllm_args(model_source: str, adapter_path: str | None, temp_dir: Path) -> list[str]:
67
96
  args = [
68
97
  "serve", model_source,
@@ -101,6 +130,7 @@ def build_vllm_args(model_source: str, adapter_path: str | None, temp_dir: Path)
101
130
  adapter_base = config.get("base_model_name_or_path")
102
131
  if adapter_base not in {os.environ["TT_BASE_MODEL"], model_source}:
103
132
  raise ValueError("The adapter base does not match the verified serving model.")
133
+ adapter_path = prepare_serving_adapter(adapter_path, temp_dir)
104
134
  base_name = "base:" + os.environ["TT_BASE_MODEL"]
105
135
  args[args.index("--served-model-name") + 1] = base_name
106
136
  args += ["--enable-lora", "--max-lora-rank", str(max(8, int(config["r"]))),