@tuned-tensor/local 0.2.9 → 0.4.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.
- package/CHANGELOG.md +86 -0
- package/README.md +110 -201
- package/dist/active-model.d.ts +16 -0
- package/dist/active-model.js +88 -0
- package/dist/active-model.js.map +1 -0
- package/dist/artifacts.d.ts +5 -4
- package/dist/artifacts.js +57 -33
- package/dist/artifacts.js.map +1 -1
- package/dist/compare.d.ts +1 -8
- package/dist/compare.js +1 -23
- package/dist/compare.js.map +1 -1
- package/dist/contracts.d.ts +234 -365
- package/dist/contracts.js +95 -224
- package/dist/contracts.js.map +1 -1
- package/dist/dataset.d.ts +11 -4
- package/dist/dataset.js +67 -125
- package/dist/dataset.js.map +1 -1
- package/dist/doctor.d.ts +2 -3
- package/dist/doctor.js +23 -161
- package/dist/doctor.js.map +1 -1
- package/dist/evaluation.d.ts +11 -71
- package/dist/evaluation.js +212 -572
- package/dist/evaluation.js.map +1 -1
- package/dist/general-regression.d.ts +10 -0
- package/dist/general-regression.js +16 -0
- package/dist/general-regression.js.map +1 -0
- package/dist/huggingface-cache.d.ts +8 -7
- package/dist/huggingface-cache.js +16 -8
- package/dist/huggingface-cache.js.map +1 -1
- package/dist/index.d.ts +2 -10
- package/dist/index.js +358 -627
- package/dist/index.js.map +1 -1
- package/dist/local-project.d.ts +1 -11
- package/dist/local-project.js +33 -61
- package/dist/local-project.js.map +1 -1
- package/dist/model-registry.d.ts +3 -16
- package/dist/model-registry.js +76 -292
- package/dist/model-registry.js.map +1 -1
- package/dist/model-server.d.ts +7 -3
- package/dist/model-server.js +34 -25
- package/dist/model-server.js.map +1 -1
- package/dist/orchestrator.d.ts +11 -25
- package/dist/orchestrator.js +393 -563
- package/dist/orchestrator.js.map +1 -1
- package/dist/prefetch.d.ts +1 -5
- package/dist/prefetch.js +14 -19
- package/dist/prefetch.js.map +1 -1
- package/dist/process-runner.d.ts +10 -29
- package/dist/process-runner.js +63 -169
- package/dist/process-runner.js.map +1 -1
- package/dist/process-training.d.ts +0 -1
- package/dist/process-training.js +10 -87
- package/dist/process-training.js.map +1 -1
- package/dist/store.d.ts +1 -3
- package/dist/store.js +92 -290
- package/dist/store.js.map +1 -1
- package/docs/architecture.md +87 -152
- package/docs/spark.md +66 -97
- package/examples/dry-runner.json +14 -0
- package/examples/general-regression.jsonl +4 -0
- package/examples/local-runner.json +13 -6
- package/examples/smoke-spec.json +41 -0
- package/package.json +6 -6
- package/training/local-runner/pyproject.toml +0 -5
- package/training/local-runner/src/evaluate.py +89 -234
- package/training/local-runner/src/model_contract.py +47 -0
- package/training/local-runner/src/prefetch.py +18 -4
- package/training/local-runner/src/serve.py +60 -117
- package/training/local-runner/src/sft_data.py +97 -0
- package/training/local-runner/src/train.py +146 -346
- package/training/local-runner/uv.lock +0 -1197
- package/dist/labeling-sanitize.d.ts +0 -31
- package/dist/labeling-sanitize.js +0 -158
- package/dist/labeling-sanitize.js.map +0 -1
- package/dist/labeling.d.ts +0 -155
- package/dist/labeling.js +0 -496
- package/dist/labeling.js.map +0 -1
- package/dist/openrouter.d.ts +0 -29
- package/dist/openrouter.js +0 -66
- package/dist/openrouter.js.map +0 -1
- package/dist/server.d.ts +0 -9
- package/dist/server.js +0 -211
- package/dist/server.js.map +0 -1
- package/docs/local-workflow-remediation-2026-07-13.md +0 -130
- package/docs/local-workflow-ux-review-2026-07-13.md +0 -458
- package/examples/dpo-preferences.jsonl +0 -2
- package/examples/dpo-run-request.json +0 -34
- package/examples/smoke-run-request.json +0 -34
- 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
|
-
|
|
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
|
|
46
|
-
global torch, PeftModel,
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
184
|
-
device = resolve_device(str(payload.get("device", "
|
|
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":
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
169
|
+
model_kwargs: dict[str, Any] = dict(source_kwargs)
|
|
206
170
|
if dtype is not None:
|
|
207
|
-
|
|
171
|
+
model_kwargs["dtype"] = dtype
|
|
208
172
|
if device == "cuda":
|
|
209
|
-
|
|
173
|
+
model_kwargs["device_map"] = {"": torch.cuda.current_device()}
|
|
210
174
|
|
|
211
|
-
model = AutoModelForCausalLM.from_pretrained(base_model, **
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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)
|
|
415
|
-
|
|
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":
|
|
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,
|