@tuned-tensor/local 0.2.9 → 0.3.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.
Files changed (82) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +84 -210
  3. package/dist/artifacts.d.ts +3 -4
  4. package/dist/artifacts.js +55 -33
  5. package/dist/artifacts.js.map +1 -1
  6. package/dist/compare.d.ts +1 -8
  7. package/dist/compare.js +1 -23
  8. package/dist/compare.js.map +1 -1
  9. package/dist/contracts.d.ts +59 -366
  10. package/dist/contracts.js +73 -224
  11. package/dist/contracts.js.map +1 -1
  12. package/dist/dataset.d.ts +2 -4
  13. package/dist/dataset.js +31 -125
  14. package/dist/dataset.js.map +1 -1
  15. package/dist/doctor.d.ts +2 -3
  16. package/dist/doctor.js +23 -161
  17. package/dist/doctor.js.map +1 -1
  18. package/dist/evaluation.d.ts +11 -71
  19. package/dist/evaluation.js +212 -572
  20. package/dist/evaluation.js.map +1 -1
  21. package/dist/huggingface-cache.d.ts +8 -7
  22. package/dist/huggingface-cache.js +16 -8
  23. package/dist/huggingface-cache.js.map +1 -1
  24. package/dist/index.d.ts +0 -10
  25. package/dist/index.js +203 -626
  26. package/dist/index.js.map +1 -1
  27. package/dist/local-project.d.ts +1 -11
  28. package/dist/local-project.js +33 -61
  29. package/dist/local-project.js.map +1 -1
  30. package/dist/model-registry.d.ts +3 -16
  31. package/dist/model-registry.js +76 -292
  32. package/dist/model-registry.js.map +1 -1
  33. package/dist/model-server.d.ts +1 -2
  34. package/dist/model-server.js +9 -18
  35. package/dist/model-server.js.map +1 -1
  36. package/dist/orchestrator.d.ts +11 -25
  37. package/dist/orchestrator.js +246 -566
  38. package/dist/orchestrator.js.map +1 -1
  39. package/dist/prefetch.d.ts +1 -5
  40. package/dist/prefetch.js +14 -19
  41. package/dist/prefetch.js.map +1 -1
  42. package/dist/process-runner.d.ts +10 -29
  43. package/dist/process-runner.js +63 -169
  44. package/dist/process-runner.js.map +1 -1
  45. package/dist/process-training.d.ts +0 -1
  46. package/dist/process-training.js +10 -87
  47. package/dist/process-training.js.map +1 -1
  48. package/dist/store.d.ts +1 -3
  49. package/dist/store.js +92 -290
  50. package/dist/store.js.map +1 -1
  51. package/docs/architecture.md +87 -152
  52. package/docs/spark.md +66 -97
  53. package/examples/dry-runner.json +14 -0
  54. package/examples/local-runner.json +8 -6
  55. package/examples/smoke-spec.json +41 -0
  56. package/package.json +6 -6
  57. package/training/local-runner/pyproject.toml +0 -5
  58. package/training/local-runner/src/evaluate.py +89 -234
  59. package/training/local-runner/src/model_contract.py +47 -0
  60. package/training/local-runner/src/prefetch.py +18 -4
  61. package/training/local-runner/src/serve.py +54 -115
  62. package/training/local-runner/src/sft_data.py +97 -0
  63. package/training/local-runner/src/train.py +146 -346
  64. package/training/local-runner/uv.lock +0 -1197
  65. package/dist/labeling-sanitize.d.ts +0 -31
  66. package/dist/labeling-sanitize.js +0 -158
  67. package/dist/labeling-sanitize.js.map +0 -1
  68. package/dist/labeling.d.ts +0 -155
  69. package/dist/labeling.js +0 -496
  70. package/dist/labeling.js.map +0 -1
  71. package/dist/openrouter.d.ts +0 -29
  72. package/dist/openrouter.js +0 -66
  73. package/dist/openrouter.js.map +0 -1
  74. package/dist/server.d.ts +0 -9
  75. package/dist/server.js +0 -211
  76. package/dist/server.js.map +0 -1
  77. package/docs/local-workflow-remediation-2026-07-13.md +0 -130
  78. package/docs/local-workflow-ux-review-2026-07-13.md +0 -458
  79. package/examples/dpo-preferences.jsonl +0 -2
  80. package/examples/dpo-run-request.json +0 -34
  81. package/examples/smoke-run-request.json +0 -34
  82. package/training/local-runner/src/train_dpo.py +0 -286
@@ -1,31 +1,34 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
- import base64
5
4
  import json
6
5
  import os
7
6
  import tarfile
8
7
  import time
9
- import urllib.request
10
- from io import BytesIO
11
8
  from pathlib import Path
12
9
  from tempfile import TemporaryDirectory
13
10
  from typing import Any
14
11
 
12
+ from model_contract import (
13
+ CERTIFIED_BASE_MODEL,
14
+ assert_certified_model_config,
15
+ )
16
+
15
17
  MAX_ARCHIVE_MEMBERS = 20_000
16
18
  MAX_ARCHIVE_EXPANDED_BYTES = 20 * 1024 * 1024 * 1024
17
19
 
18
20
 
19
21
  def load_json(path: Path) -> dict[str, Any]:
20
- return json.loads(path.read_text())
22
+ value = json.loads(path.read_text(encoding="utf-8"))
23
+ if not isinstance(value, dict):
24
+ raise ValueError(f"Expected a JSON object in {path}")
25
+ return value
21
26
 
22
27
 
23
28
  def configure_hugging_face_cache(cache_home: str | None) -> None:
24
29
  """Treat model_cache as HF_HOME and keep legacy overrides consistent."""
25
30
  if not cache_home:
26
31
  return
27
- # Preserve the lexical absolute path chosen by the Node runner rather than
28
- # canonicalizing platform symlinks such as macOS /var -> /private/var.
29
32
  home = Path(cache_home).expanduser().absolute()
30
33
  hub = home / "hub"
31
34
  os.environ.update({
@@ -42,27 +45,19 @@ def configure_hugging_face_cache(cache_home: str | None) -> None:
42
45
 
43
46
 
44
47
  def import_runtime_dependencies() -> None:
45
- """Import libraries that read Hugging Face cache settings at import time."""
46
- global torch, PeftModel, Image
47
- global AutoModelForCausalLM, AutoModelForImageTextToText, AutoProcessor, AutoTokenizer
48
+ """Import libraries only after Hugging Face cache settings are final."""
49
+ global torch, PeftModel, AutoModelForCausalLM, AutoTokenizer
48
50
 
49
51
  import torch as torch_module
50
52
  from peft import PeftModel as peft_model
51
- from PIL import Image as pil_image
52
53
  from transformers import (
53
54
  AutoModelForCausalLM as auto_model_for_causal_lm,
54
- AutoModelForImageTextToText as auto_model_for_image_text_to_text,
55
- AutoProcessor as auto_processor,
56
55
  AutoTokenizer as auto_tokenizer,
57
56
  )
58
57
 
59
58
  torch = torch_module
60
59
  PeftModel = peft_model
61
- Image = pil_image
62
- Image.MAX_IMAGE_PIXELS = 20_000_000
63
60
  AutoModelForCausalLM = auto_model_for_causal_lm
64
- AutoModelForImageTextToText = auto_model_for_image_text_to_text
65
- AutoProcessor = auto_processor
66
61
  AutoTokenizer = auto_tokenizer
67
62
 
68
63
 
@@ -74,6 +69,27 @@ def strip_file_uri(value: str | None) -> str | None:
74
69
  return value
75
70
 
76
71
 
72
+ def _extract_adapter_archive(path: Path, destination: Path) -> None:
73
+ destination.mkdir(parents=True, exist_ok=True)
74
+ destination_root = destination.resolve()
75
+ with tarfile.open(path, "r:gz") as archive:
76
+ members = archive.getmembers()
77
+ if len(members) > MAX_ARCHIVE_MEMBERS:
78
+ raise ValueError(f"Model archive exceeds {MAX_ARCHIVE_MEMBERS} members")
79
+ expanded_bytes = sum(max(0, member.size) for member in members if member.isfile())
80
+ if expanded_bytes > MAX_ARCHIVE_EXPANDED_BYTES:
81
+ raise ValueError("Model archive exceeds the 20 GiB expanded-size limit")
82
+ for member in members:
83
+ member_path = (destination / member.name).resolve()
84
+ try:
85
+ member_path.relative_to(destination_root)
86
+ except ValueError as exc:
87
+ raise ValueError(f"Unsafe archive member: {member.name}") from exc
88
+ if member.issym() or member.islnk() or member.isdev():
89
+ raise ValueError(f"Unsafe archive member type: {member.name}")
90
+ archive.extractall(destination)
91
+
92
+
77
93
  def resolve_adapter_path(value: str | None, tmp: Path) -> str | None:
78
94
  path_value = strip_file_uri(value)
79
95
  if not path_value:
@@ -81,40 +97,20 @@ def resolve_adapter_path(value: str | None, tmp: Path) -> str | None:
81
97
  path = Path(path_value)
82
98
  if path.is_file() and path.name.endswith(".tar.gz"):
83
99
  extracted = tmp / "adapter"
84
- extracted.mkdir(parents=True, exist_ok=True)
85
- with tarfile.open(path, "r:gz") as tar:
86
- members = tar.getmembers()
87
- if len(members) > MAX_ARCHIVE_MEMBERS:
88
- raise ValueError(f"Model archive exceeds {MAX_ARCHIVE_MEMBERS} members")
89
- expanded_bytes = sum(max(0, member.size) for member in members if member.isfile())
90
- if expanded_bytes > MAX_ARCHIVE_EXPANDED_BYTES:
91
- raise ValueError("Model archive exceeds the 20 GiB expanded-size limit")
92
- for member in members:
93
- destination = (extracted / member.name).resolve()
94
- try:
95
- destination.relative_to(extracted.resolve())
96
- except ValueError:
97
- raise ValueError(f"Unsafe archive member: {member.name}")
98
- if member.issym() or member.islnk() or member.isdev():
99
- raise ValueError(f"Unsafe archive member type: {member.name}")
100
- tar.extractall(extracted)
101
- candidates = [candidate for candidate in extracted.rglob("adapter_config.json")]
100
+ _extract_adapter_archive(path, extracted)
101
+ candidates = sorted(extracted.rglob("adapter_config.json"))
102
102
  if candidates:
103
103
  return str(candidates[0].parent)
104
- directories = [candidate for candidate in extracted.iterdir() if candidate.is_dir()]
105
- return str(directories[0] if len(directories) == 1 else extracted)
104
+ raise ValueError(f"Adapter archive contains no adapter_config.json: {path}")
105
+ if not path.is_dir():
106
+ raise ValueError(f"Adapter path is not a directory or .tar.gz archive: {path}")
106
107
  return str(path)
107
108
 
108
109
 
109
- def fallback_prompt(system: str, prompt: str) -> str:
110
- return f"system: {system}\nuser: {prompt}\nassistant:"
111
-
112
-
113
110
  def format_prompt(
114
111
  tokenizer: Any,
115
112
  system: str,
116
113
  prompt: str,
117
- chat_template_kwargs: dict[str, Any] | None = None,
118
114
  ) -> str:
119
115
  messages = [
120
116
  {"role": "system", "content": system},
@@ -125,90 +121,59 @@ def format_prompt(
125
121
  messages,
126
122
  tokenize=False,
127
123
  add_generation_prompt=True,
128
- **(chat_template_kwargs or {}),
129
124
  )
130
- except Exception:
131
- return fallback_prompt(system, prompt)
125
+ except Exception as exc:
126
+ raise ValueError(f"Qwen chat-template rendering failed: {exc}") from exc
132
127
 
133
128
 
134
129
  def resolve_device(device: str) -> str:
135
- if device == "auto":
136
- if torch.cuda.is_available():
137
- return "cuda"
138
- if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
139
- return "mps"
140
- return "cpu"
130
+ if device not in {"cuda", "cpu"}:
131
+ raise ValueError(f"device must be cuda or cpu; got {device!r}")
132
+ if device == "cuda" and not torch.cuda.is_available():
133
+ raise RuntimeError("CUDA evaluation was requested, but torch.cuda.is_available() is false")
141
134
  return device
142
135
 
143
136
 
144
- def image_from_data_uri(value: str) -> Image.Image:
145
- _, encoded = value.split(",", 1)
146
- return Image.open(BytesIO(base64.b64decode(encoded))).convert("RGB")
147
-
148
-
149
- def load_image(value: str, base_dir: Path) -> Image.Image:
150
- if value.startswith("data:"):
151
- return image_from_data_uri(value)
152
- if value.startswith("http://") or value.startswith("https://"):
153
- with urllib.request.urlopen(value, timeout=30) as response:
154
- return Image.open(BytesIO(response.read())).convert("RGB")
155
- path_value = value[7:] if value.startswith("file://") else value
156
- path = Path(path_value).expanduser()
157
- if not path.is_absolute():
158
- path = base_dir / path
159
- return Image.open(path).convert("RGB")
160
-
161
-
162
- def example_assets(example: dict[str, Any]) -> list[str]:
163
- assets = []
164
- for asset in example.get("input_assets") or []:
165
- if not isinstance(asset, dict):
166
- continue
167
- for key in ("image", "path", "uri", "data_uri"):
168
- value = asset.get(key)
169
- if isinstance(value, str) and value:
170
- assets.append(value)
171
- break
172
- return assets
173
-
174
-
175
- def should_use_multimodal(payload: dict[str, Any]) -> bool:
176
- if payload.get("model_loader") == "image_text_to_text":
177
- return True
178
- return any(example_assets(example) for example in payload.get("examples", []))
137
+ def _assert_certified_model_source(base_model: str) -> None:
138
+ if not Path(base_model).exists() and base_model != CERTIFIED_BASE_MODEL:
139
+ raise ValueError(
140
+ f"The bundled evaluator currently certifies only {CERTIFIED_BASE_MODEL}; got {base_model!r}"
141
+ )
179
142
 
180
143
 
181
144
  def load_text_model(payload: dict[str, Any], adapter_path: str | None):
182
- base_model = payload["base_model"]
183
- trust_remote_code = bool(payload.get("trust_remote_code", True))
184
- device = resolve_device(str(payload.get("device", "auto")))
145
+ base_model = str(payload["base_model"])
146
+ _assert_certified_model_source(base_model)
147
+ device = resolve_device(str(payload.get("device", "cuda")))
185
148
  token = os.getenv("HF_TOKEN")
186
149
  revision = payload.get("base_model_revision")
187
150
  source_kwargs: dict[str, Any] = {
188
- "trust_remote_code": trust_remote_code,
151
+ "trust_remote_code": False,
189
152
  "token": token,
190
153
  }
154
+ if not Path(base_model).exists():
155
+ source_kwargs["local_files_only"] = True
191
156
  if revision and not Path(base_model).exists():
192
157
  source_kwargs["revision"] = revision
193
158
 
194
- tokenizer = AutoTokenizer.from_pretrained(
195
- base_model,
196
- **source_kwargs,
197
- )
198
- if tokenizer.pad_token is None:
159
+ tokenizer = AutoTokenizer.from_pretrained(base_model, **source_kwargs)
160
+ if tokenizer.eos_token_id is None:
161
+ raise ValueError(f"{CERTIFIED_BASE_MODEL} tokenizer has no EOS token")
162
+ if tokenizer.pad_token_id is None:
199
163
  tokenizer.pad_token = tokenizer.eos_token
200
164
 
201
165
  dtype = None
202
166
  if device == "cuda":
203
167
  dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
204
168
 
205
- kwargs: dict[str, Any] = dict(source_kwargs)
169
+ model_kwargs: dict[str, Any] = dict(source_kwargs)
206
170
  if dtype is not None:
207
- kwargs["torch_dtype"] = dtype
171
+ model_kwargs["dtype"] = dtype
208
172
  if device == "cuda":
209
- kwargs["device_map"] = "auto"
173
+ model_kwargs["device_map"] = {"": torch.cuda.current_device()}
210
174
 
211
- model = AutoModelForCausalLM.from_pretrained(base_model, **kwargs)
175
+ model = AutoModelForCausalLM.from_pretrained(base_model, **model_kwargs)
176
+ assert_certified_model_config(model.config)
212
177
  if adapter_path:
213
178
  model = PeftModel.from_pretrained(model, adapter_path)
214
179
  if device != "cuda":
@@ -217,47 +182,7 @@ def load_text_model(payload: dict[str, Any], adapter_path: str | None):
217
182
  return model, tokenizer, device
218
183
 
219
184
 
220
- def load_multimodal_model(payload: dict[str, Any], adapter_path: str | None):
221
- base_model = payload["base_model"]
222
- trust_remote_code = bool(payload.get("trust_remote_code", True))
223
- device = resolve_device(str(payload.get("device", "auto")))
224
- token = os.getenv("HF_TOKEN")
225
- revision = payload.get("base_model_revision")
226
- source_kwargs: dict[str, Any] = {
227
- "trust_remote_code": trust_remote_code,
228
- "token": token,
229
- }
230
- if revision and not Path(base_model).exists():
231
- source_kwargs["revision"] = revision
232
-
233
- processor = AutoProcessor.from_pretrained(
234
- base_model,
235
- **source_kwargs,
236
- )
237
- if getattr(processor, "tokenizer", None) is not None and processor.tokenizer.pad_token is None:
238
- processor.tokenizer.pad_token = processor.tokenizer.eos_token
239
-
240
- dtype = None
241
- if device == "cuda":
242
- dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
243
- kwargs: dict[str, Any] = dict(source_kwargs)
244
- if dtype is not None:
245
- kwargs["torch_dtype"] = dtype
246
- if device == "cuda":
247
- kwargs["device_map"] = "auto"
248
-
249
- model = AutoModelForImageTextToText.from_pretrained(base_model, **kwargs)
250
- if adapter_path:
251
- model = PeftModel.from_pretrained(model, adapter_path)
252
- if device != "cuda":
253
- model = model.to(device)
254
- model.eval()
255
- return model, processor, device
256
-
257
-
258
185
  def sampling_kwargs(generation: dict[str, Any]) -> dict[str, Any]:
259
- """Greedy decoding must not pass sampling flags: transformers warns that
260
- temperature/top_p are ignored when do_sample=False."""
261
186
  temperature = float(generation.get("temperature", 0))
262
187
  if temperature <= 0:
263
188
  return {"do_sample": False}
@@ -274,16 +199,16 @@ def generate_text_one(
274
199
  system: str,
275
200
  example: dict[str, Any],
276
201
  generation: dict[str, Any],
277
- chat_template_kwargs: dict[str, Any] | None = None,
278
202
  ) -> dict[str, Any]:
203
+ if example.get("input_assets"):
204
+ raise ValueError("The bundled Qwen SFT evaluator is text-only and does not accept input_assets")
279
205
  prompt = str(example["input"])
280
- expected = str(example["output"])
281
- formatted = format_prompt(tokenizer, system, prompt, chat_template_kwargs)
206
+ formatted = format_prompt(tokenizer, system, prompt)
282
207
  inputs = tokenizer(formatted, return_tensors="pt")
283
208
  target_device = next(model.parameters()).device
284
209
  inputs = {key: value.to(target_device) for key, value in inputs.items()}
285
210
  started = time.perf_counter()
286
- with torch.no_grad():
211
+ with torch.inference_mode():
287
212
  generated = model.generate(
288
213
  **inputs,
289
214
  max_new_tokens=int(generation.get("max_new_tokens", 256)),
@@ -295,66 +220,7 @@ def generate_text_one(
295
220
  output_tokens = generated[0][input_length:]
296
221
  actual = tokenizer.decode(output_tokens, skip_special_tokens=True).strip()
297
222
  return {
298
- "prompt": prompt,
299
- "expected": expected,
300
- "actual": actual,
301
- "latency_ms": latency_ms,
302
- }
303
-
304
-
305
- def generate_multimodal_one(
306
- model: Any,
307
- processor: Any,
308
- system: str,
309
- example: dict[str, Any],
310
- generation: dict[str, Any],
311
- base_dir: Path,
312
- chat_template_kwargs: dict[str, Any] | None = None,
313
- ) -> dict[str, Any]:
314
- prompt = str(example["input"])
315
- expected = str(example["output"])
316
- image_values = example_assets(example)
317
- images = [load_image(value, base_dir) for value in image_values]
318
- user_content: list[dict[str, str]] = [{"type": "image"} for _ in images]
319
- user_content.append({"type": "text", "text": prompt})
320
- messages = [
321
- {"role": "system", "content": [{"type": "text", "text": system}]},
322
- {"role": "user", "content": user_content},
323
- ]
324
- text = processor.apply_chat_template(
325
- messages,
326
- tokenize=False,
327
- add_generation_prompt=True,
328
- **(chat_template_kwargs or {}),
329
- )
330
- inputs = processor(
331
- text=[text],
332
- images=images or None,
333
- return_tensors="pt",
334
- )
335
- target_device = next(model.parameters()).device
336
- inputs = {
337
- key: value.to(target_device) if hasattr(value, "to") else value
338
- for key, value in inputs.items()
339
- }
340
- started = time.perf_counter()
341
- with torch.no_grad():
342
- generated = model.generate(
343
- **inputs,
344
- max_new_tokens=int(generation.get("max_new_tokens", 256)),
345
- pad_token_id=processor.tokenizer.eos_token_id,
346
- **sampling_kwargs(generation),
347
- )
348
- latency_ms = max(0, round((time.perf_counter() - started) * 1000))
349
- input_length = int(inputs["input_ids"].shape[-1])
350
- actual = processor.batch_decode(
351
- generated[:, input_length:],
352
- skip_special_tokens=True,
353
- clean_up_tokenization_spaces=False,
354
- )[0].strip()
355
- return {
356
- "prompt": prompt,
357
- "expected": expected,
223
+ "id": str(example["id"]),
358
224
  "actual": actual,
359
225
  "latency_ms": latency_ms,
360
226
  }
@@ -367,41 +233,29 @@ def main() -> None:
367
233
  args = parser.parse_args()
368
234
 
369
235
  payload = load_json(Path(args.input))
236
+ if payload.get("protocol_version") != 2:
237
+ raise ValueError("Unsupported inference protocol; expected protocol_version 2")
238
+ if payload.get("model_loader") not in (None, "causal_lm"):
239
+ raise ValueError("The bundled evaluator is text-only and requires model_loader=causal_lm")
370
240
  configure_hugging_face_cache(payload.get("model_cache"))
371
241
  import_runtime_dependencies()
372
242
 
373
- with TemporaryDirectory() as tmp_dir:
243
+ with TemporaryDirectory(prefix="tt-local-evaluate-") as tmp_dir:
374
244
  adapter_path = resolve_adapter_path(payload.get("adapter_path"), Path(tmp_dir))
375
245
  generation = payload.get("generation", {})
376
- chat_template_kwargs = payload.get("chat_template_kwargs") or None
377
- base_dir = Path(args.input).parent
378
- if should_use_multimodal(payload):
379
- model, processor, _device = load_multimodal_model(payload, adapter_path)
380
- results = [
381
- generate_multimodal_one(
382
- model,
383
- processor,
384
- str(payload.get("system", "")),
385
- example,
386
- generation,
387
- base_dir,
388
- chat_template_kwargs,
389
- )
390
- for example in payload.get("examples", [])
391
- ]
392
- else:
393
- model, tokenizer, _device = load_text_model(payload, adapter_path)
394
- results = [
395
- generate_text_one(
396
- model,
397
- tokenizer,
398
- str(payload.get("system", "")),
399
- example,
400
- generation,
401
- chat_template_kwargs,
402
- )
403
- for example in payload.get("examples", [])
404
- ]
246
+ if not isinstance(generation, dict):
247
+ raise ValueError("generation must be a JSON object")
248
+ model, tokenizer, _device = load_text_model(payload, adapter_path)
249
+ results = [
250
+ generate_text_one(
251
+ model,
252
+ tokenizer,
253
+ str(payload.get("system", "")),
254
+ example,
255
+ generation,
256
+ )
257
+ for example in payload.get("examples", [])
258
+ ]
405
259
 
406
260
  output = {
407
261
  "provider": "transformers",
@@ -411,8 +265,9 @@ def main() -> None:
411
265
  "generation_config": generation,
412
266
  "results": results,
413
267
  }
414
- Path(args.output).parent.mkdir(parents=True, exist_ok=True)
415
- Path(args.output).write_text(json.dumps(output, indent=2))
268
+ output_path = Path(args.output)
269
+ output_path.parent.mkdir(parents=True, exist_ok=True)
270
+ output_path.write_text(json.dumps(output, indent=2), encoding="utf-8")
416
271
 
417
272
 
418
273
  if __name__ == "__main__":
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ CERTIFIED_BASE_MODEL = "Qwen/Qwen3.5-2B"
7
+ CERTIFIED_TEXT_CONFIG = {
8
+ "model_type": "qwen3_5_text",
9
+ "hidden_size": 2048,
10
+ "num_hidden_layers": 24,
11
+ "num_attention_heads": 8,
12
+ "num_key_value_heads": 2,
13
+ "intermediate_size": 6144,
14
+ "vocab_size": 248320,
15
+ }
16
+
17
+
18
+ def _field(value: Any, name: str) -> Any:
19
+ if isinstance(value, dict):
20
+ return value.get(name)
21
+ return getattr(value, name, None)
22
+
23
+
24
+ def assert_certified_model_config(value: Any, label: str = "base-model config") -> None:
25
+ model_type = _field(value, "model_type")
26
+ architectures = _field(value, "architectures")
27
+ if model_type == "qwen3_5":
28
+ text_config = _field(value, "text_config")
29
+ if (
30
+ not isinstance(architectures, (list, tuple))
31
+ or "Qwen3_5ForConditionalGeneration" not in architectures
32
+ or text_config is None
33
+ ):
34
+ raise ValueError(f"{label} is not the certified {CERTIFIED_BASE_MODEL} architecture")
35
+ elif model_type == "qwen3_5_text":
36
+ # AutoModelForCausalLM exposes the selected text sub-config after it
37
+ # dispatches the verified repository-level Qwen3.5 config.
38
+ text_config = value
39
+ else:
40
+ raise ValueError(f"{label} is not the certified {CERTIFIED_BASE_MODEL} architecture")
41
+ for name, expected in CERTIFIED_TEXT_CONFIG.items():
42
+ actual = _field(text_config, name)
43
+ if actual != expected:
44
+ raise ValueError(
45
+ f"{label} is not the certified {CERTIFIED_BASE_MODEL} architecture: "
46
+ f"text_config.{name} must be {expected!r}, got {actual!r}"
47
+ )
@@ -2,13 +2,18 @@ import argparse
2
2
  import hashlib
3
3
  import json
4
4
  import os
5
+ import re
5
6
  from pathlib import Path
6
7
  from typing import Any
7
8
 
9
+ from model_contract import (
10
+ CERTIFIED_BASE_MODEL,
11
+ assert_certified_model_config,
12
+ )
13
+
8
14
  ALLOW_PATTERNS = [
9
15
  "*.json",
10
16
  "*.model",
11
- "*.py",
12
17
  "*.safetensors",
13
18
  "*.safetensors.index.json",
14
19
  "*.tiktoken",
@@ -79,7 +84,8 @@ def verify_snapshot(snapshot: Path) -> tuple[list[Path], int]:
79
84
  if not config.is_file() or config.stat().st_size == 0:
80
85
  raise ValueError(f"Cached snapshot is missing a non-empty config.json: {snapshot}")
81
86
  try:
82
- json.loads(config.read_text(encoding="utf-8"))
87
+ parsed_config = json.loads(config.read_text(encoding="utf-8"))
88
+ assert_certified_model_config(parsed_config, f"Cached config {config}")
83
89
  except (OSError, json.JSONDecodeError) as exc:
84
90
  raise ValueError(f"Cached snapshot has an invalid config.json: {snapshot}") from exc
85
91
 
@@ -138,6 +144,10 @@ def main() -> None:
138
144
 
139
145
  payload = json.loads(Path(args.input).read_text(encoding="utf-8"))
140
146
  base_model = str(payload["base_model"])
147
+ if base_model != CERTIFIED_BASE_MODEL:
148
+ raise ValueError(
149
+ f"The bundled trainer currently certifies only {CERTIFIED_BASE_MODEL}; got {base_model!r}"
150
+ )
141
151
  cache_dir = payload.get("model_cache")
142
152
  configure_hugging_face_cache(cache_dir)
143
153
 
@@ -156,18 +166,22 @@ def main() -> None:
156
166
  local_files_only=bool(payload.get("local_files_only", False)),
157
167
  )
158
168
  snapshot = Path(snapshot_path)
169
+ snapshot_revision = snapshot.name
170
+ if not re.fullmatch(r"[0-9a-fA-F]{40}", snapshot_revision):
171
+ raise ValueError(
172
+ "Hugging Face snapshot did not resolve to a 40-character immutable commit SHA"
173
+ )
159
174
  print(f"Verifying snapshot files under {snapshot}...", flush=True)
160
175
  snapshot_files, verified_blob_count = verify_snapshot(snapshot)
161
176
 
162
177
  write_json(args.output, {
163
178
  "ok": True,
164
179
  "base_model": base_model,
165
- "loader": payload.get("loader"),
166
180
  "model_cache": str(constants.HF_HOME),
167
181
  "hf_home": str(constants.HF_HOME),
168
182
  "hub_cache": str(constants.HF_HUB_CACHE),
169
183
  "snapshot_path": snapshot_path,
170
- "snapshot_revision": Path(snapshot_path).name,
184
+ "snapshot_revision": snapshot_revision.lower(),
171
185
  "file_count": len(snapshot_files),
172
186
  "size_bytes": sum(path.stat().st_size for path in snapshot_files),
173
187
  "verified_blob_count": verified_blob_count,