@tuned-tensor/cli 0.19.0 → 0.21.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/README.md +45 -9
- package/dist/{chunk-4Q54NHKR.js → chunk-PVLX7LJI.js} +381 -148
- package/dist/chunk-PVLX7LJI.js.map +1 -0
- package/dist/index.js +2051 -417
- package/dist/index.js.map +1 -1
- package/dist/local-runtime.js +41 -6
- package/dist/local-runtime.js.map +1 -1
- package/docs/local-runtime/architecture.md +6 -1
- package/docs/local-runtime/decision.md +142 -0
- package/docs/spec-architecture.html +2 -2
- package/docs/spec-workflow.md +16 -7
- package/examples/single-spec/decision/tunedtensor.json +630 -0
- package/package.json +5 -2
- package/training/adapter/src/evaluate.py +14 -7
- package/training/adapter/src/model_contract.py +38 -5
- package/training/adapter/src/prefetch.py +6 -1
- package/training/adapter/src/serve.py +37 -11
- package/training/adapter/src/train.py +8 -0
- package/training/decision/pyproject.toml +25 -0
- package/training/decision/src/common.py +127 -0
- package/training/decision/src/evaluate.py +70 -0
- package/training/decision/src/train.py +181 -0
- package/training/decision/uv.lock +1175 -0
- package/dist/chunk-4Q54NHKR.js.map +0 -1
|
@@ -10,12 +10,12 @@ from tempfile import TemporaryDirectory
|
|
|
10
10
|
from typing import Any
|
|
11
11
|
|
|
12
12
|
from model_contract import (
|
|
13
|
-
CERTIFIED_BASE_MODELS,
|
|
14
13
|
CERTIFIED_BASE_MODEL,
|
|
15
14
|
assert_certified_base_model,
|
|
16
15
|
assert_certified_base_model_revision,
|
|
17
16
|
assert_certified_model_config,
|
|
18
17
|
chat_template_kwargs,
|
|
18
|
+
uncertified_load_kwargs,
|
|
19
19
|
)
|
|
20
20
|
|
|
21
21
|
MAX_ARCHIVE_MEMBERS = 20_000
|
|
@@ -145,10 +145,12 @@ def resolve_device(device: str) -> str:
|
|
|
145
145
|
return device
|
|
146
146
|
|
|
147
147
|
|
|
148
|
-
def _assert_certified_model_source(base_model: str) -> None:
|
|
149
|
-
|
|
148
|
+
def _assert_certified_model_source(base_model: str, model_source: str) -> None:
|
|
149
|
+
# A hub source must be the requested model; certified or not, it is
|
|
150
|
+
# checked by assert_certified_base_model.
|
|
151
|
+
if not Path(model_source).exists() and model_source != base_model:
|
|
150
152
|
raise ValueError(
|
|
151
|
-
f"The bundled evaluator does not certify {
|
|
153
|
+
f"The bundled evaluator does not certify {model_source!r}"
|
|
152
154
|
)
|
|
153
155
|
|
|
154
156
|
|
|
@@ -160,9 +162,14 @@ def load_text_model(
|
|
|
160
162
|
base_model = str(payload["base_model"])
|
|
161
163
|
model_source = str(payload.get("model_source", base_model))
|
|
162
164
|
assert_certified_base_model(base_model, "Evaluation base model")
|
|
163
|
-
_assert_certified_model_source(model_source)
|
|
165
|
+
_assert_certified_model_source(base_model, model_source)
|
|
164
166
|
revision = payload.get("base_model_revision")
|
|
165
|
-
assert_certified_base_model_revision(
|
|
167
|
+
assert_certified_base_model_revision(
|
|
168
|
+
base_model,
|
|
169
|
+
revision,
|
|
170
|
+
"Evaluation base model revision",
|
|
171
|
+
local_source=Path(model_source).is_dir(),
|
|
172
|
+
)
|
|
166
173
|
device = resolve_device(str(payload.get("device", "cuda")))
|
|
167
174
|
token = os.getenv("HF_TOKEN")
|
|
168
175
|
source_kwargs: dict[str, Any] = {
|
|
@@ -184,7 +191,7 @@ def load_text_model(
|
|
|
184
191
|
if device == "cuda":
|
|
185
192
|
dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
|
|
186
193
|
|
|
187
|
-
model_kwargs: dict[str, Any] =
|
|
194
|
+
model_kwargs: dict[str, Any] = {**source_kwargs, **uncertified_load_kwargs(base_model)}
|
|
188
195
|
if dtype is not None:
|
|
189
196
|
model_kwargs["dtype"] = dtype
|
|
190
197
|
if device == "cuda":
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import re
|
|
3
4
|
from typing import Any
|
|
4
5
|
|
|
5
6
|
|
|
@@ -15,6 +16,10 @@ NEMOTRON_BASE_MODEL_REVISION = "ce38b6ab8b252b4b8ee7165b4605e93191cafd73"
|
|
|
15
16
|
# Immutable Hugging Face revision reviewed and certified for Muse Glimmer.
|
|
16
17
|
MUSE_GLIMMER_BASE_MODEL_REVISION = "a4e59da52a7bc87ae7251dd5545c0dd437c44b68"
|
|
17
18
|
CERTIFIED_BASE_MODELS = (CERTIFIED_BASE_MODEL, NEMOTRON_BASE_MODEL, MUSE_GLIMMER_BASE_MODEL)
|
|
19
|
+
# Any other Hugging Face repo may be trained as an uncertified causal LM when
|
|
20
|
+
# the run pins an immutable commit. Node enforces the same contract.
|
|
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
|
+
COMMIT_SHA = re.compile(r"[0-9a-fA-F]{40}")
|
|
18
23
|
CERTIFIED_QWEN_TEXT_CONFIG = {
|
|
19
24
|
"model_type": "qwen3_5_text",
|
|
20
25
|
"hidden_size": 2048,
|
|
@@ -58,6 +63,9 @@ def assert_certified_model_config(
|
|
|
58
63
|
label: str = "base-model config",
|
|
59
64
|
expected_model_id: str | None = None,
|
|
60
65
|
) -> None:
|
|
66
|
+
if expected_model_id and not is_certified_base_model(expected_model_id):
|
|
67
|
+
# No reviewed architecture exists for an uncertified model.
|
|
68
|
+
return
|
|
61
69
|
model_type = _field(value, "model_type")
|
|
62
70
|
architectures = _field(value, "architectures")
|
|
63
71
|
|
|
@@ -126,17 +134,30 @@ def assert_certified_model_config(
|
|
|
126
134
|
)
|
|
127
135
|
|
|
128
136
|
|
|
137
|
+
def is_certified_base_model(model_id: str) -> bool:
|
|
138
|
+
return model_id in CERTIFIED_BASE_MODELS
|
|
139
|
+
|
|
140
|
+
|
|
129
141
|
def assert_certified_base_model(model_id: str, label: str = "base model") -> None:
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
142
|
+
"""Accept a certified model or an uncertified Hugging Face repo id."""
|
|
143
|
+
if is_certified_base_model(model_id) or HUGGING_FACE_REPO_ID.fullmatch(model_id):
|
|
144
|
+
return
|
|
145
|
+
supported = ", ".join(CERTIFIED_BASE_MODELS)
|
|
146
|
+
raise ValueError(f"{label} must be one of {supported} or a Hugging Face repo id; got {model_id!r}")
|
|
133
147
|
|
|
134
148
|
|
|
135
|
-
def assert_certified_base_model_revision(
|
|
149
|
+
def assert_certified_base_model_revision(
|
|
150
|
+
base_model: str,
|
|
151
|
+
revision: str | None,
|
|
152
|
+
label: str = "base model revision",
|
|
153
|
+
*,
|
|
154
|
+
local_source: bool = False,
|
|
155
|
+
) -> None:
|
|
136
156
|
"""Require the certified immutable revision for pinned training models.
|
|
137
157
|
|
|
138
158
|
Qwen, Nemotron, and Muse Glimmer loads are bound here; local snapshot
|
|
139
|
-
contents are additionally verified by the Node runtime.
|
|
159
|
+
contents are additionally verified by the Node runtime. An uncertified
|
|
160
|
+
model must pin a commit SHA unless it loads from a local directory.
|
|
140
161
|
"""
|
|
141
162
|
expected = {
|
|
142
163
|
CERTIFIED_BASE_MODEL: QWEN_BASE_MODEL_REVISION,
|
|
@@ -144,6 +165,13 @@ def assert_certified_base_model_revision(base_model: str, revision: str | None,
|
|
|
144
165
|
MUSE_GLIMMER_BASE_MODEL: MUSE_GLIMMER_BASE_MODEL_REVISION,
|
|
145
166
|
}.get(base_model)
|
|
146
167
|
if expected is None:
|
|
168
|
+
if revision is None and local_source:
|
|
169
|
+
return
|
|
170
|
+
if revision is None or not COMMIT_SHA.fullmatch(str(revision)):
|
|
171
|
+
raise ValueError(
|
|
172
|
+
f"{label} must be a 40-character commit SHA for uncertified base model {base_model}; "
|
|
173
|
+
f"got {revision!r}"
|
|
174
|
+
)
|
|
147
175
|
return
|
|
148
176
|
if revision is None:
|
|
149
177
|
raise ValueError(f"{label} is required for {base_model}")
|
|
@@ -167,3 +195,8 @@ def chat_template_kwargs(model_id: str) -> dict[str, Any]:
|
|
|
167
195
|
if model_id == NEMOTRON_BASE_MODEL:
|
|
168
196
|
return {"enable_thinking": False}
|
|
169
197
|
return {}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def uncertified_load_kwargs(model_id: str) -> dict[str, Any]:
|
|
201
|
+
"""Refuse pickle weights for models TT has not reviewed."""
|
|
202
|
+
return {} if is_certified_base_model(model_id) else {"use_safetensors": True}
|
|
@@ -10,6 +10,7 @@ from model_contract import (
|
|
|
10
10
|
assert_certified_base_model,
|
|
11
11
|
assert_certified_base_model_revision,
|
|
12
12
|
assert_certified_model_config,
|
|
13
|
+
is_certified_base_model,
|
|
13
14
|
)
|
|
14
15
|
|
|
15
16
|
ALLOW_PATTERNS = [
|
|
@@ -99,6 +100,10 @@ def verify_snapshot(snapshot: Path, expected_model_id: str | None = None) -> tup
|
|
|
99
100
|
except (OSError, json.JSONDecodeError) as exc:
|
|
100
101
|
raise ValueError(f"Cached snapshot has an invalid config.json: {snapshot}") from exc
|
|
101
102
|
|
|
103
|
+
if expected_model_id and not is_certified_base_model(expected_model_id):
|
|
104
|
+
if not any(path.stat().st_size > 0 for path in snapshot.glob("*.safetensors")):
|
|
105
|
+
raise ValueError("Uncertified base models require non-empty safetensors weights; pickle-only checkpoints are unsupported")
|
|
106
|
+
|
|
102
107
|
weight_files = sorted(snapshot.glob("*.safetensors")) + sorted(snapshot.glob("pytorch_model*.bin"))
|
|
103
108
|
index_files = sorted(snapshot.glob("*.safetensors.index.json")) + sorted(snapshot.glob("pytorch_model*.bin.index.json"))
|
|
104
109
|
for index in index_files:
|
|
@@ -175,7 +180,7 @@ def main() -> None:
|
|
|
175
180
|
revision=payload.get("revision"),
|
|
176
181
|
token=os.getenv("HF_TOKEN"),
|
|
177
182
|
allow_patterns=ALLOW_PATTERNS,
|
|
178
|
-
ignore_patterns=IGNORE_PATTERNS,
|
|
183
|
+
ignore_patterns=IGNORE_PATTERNS + ([] if is_certified_base_model(base_model) else ["pytorch_model*.bin"]),
|
|
179
184
|
local_files_only=bool(payload.get("local_files_only", False)),
|
|
180
185
|
)
|
|
181
186
|
snapshot = Path(snapshot_path)
|
|
@@ -11,7 +11,11 @@ from pathlib import Path
|
|
|
11
11
|
from tempfile import TemporaryDirectory
|
|
12
12
|
|
|
13
13
|
from evaluate import configure_hugging_face_cache, resolve_adapter_path
|
|
14
|
-
from model_contract import
|
|
14
|
+
from model_contract import (
|
|
15
|
+
assert_certified_base_model_revision,
|
|
16
|
+
assert_certified_model_config,
|
|
17
|
+
is_certified_base_model,
|
|
18
|
+
)
|
|
15
19
|
|
|
16
20
|
class BearerAuthMiddleware:
|
|
17
21
|
"""Keep TT's auth boundary on ALL routes, including upstream admin APIs.
|
|
@@ -38,6 +42,27 @@ class BearerAuthMiddleware:
|
|
|
38
42
|
await self.app(scope, receive, send)
|
|
39
43
|
|
|
40
44
|
|
|
45
|
+
def native_chat_template(model_source: str) -> str:
|
|
46
|
+
source = Path(model_source)
|
|
47
|
+
template_file = source / "chat_template.jinja"
|
|
48
|
+
template = (template_file.read_text(encoding="utf-8") if template_file.is_file()
|
|
49
|
+
else json.loads((source / "tokenizer_config.json").read_text())["chat_template"])
|
|
50
|
+
if not isinstance(template, str):
|
|
51
|
+
raise ValueError("Serving requires an unambiguous string chat template.")
|
|
52
|
+
return template
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def pin_native_content_format(args: list[str], model_source: str) -> None:
|
|
56
|
+
if "--chat-template" not in args:
|
|
57
|
+
return
|
|
58
|
+
# The wrapper handles system content parts, which confuses upstream's AST
|
|
59
|
+
# detection for string-only native templates. Detect the ORIGINAL template
|
|
60
|
+
# using the detector from our pinned vLLM runtime, then pin that format.
|
|
61
|
+
from vllm.renderers.hf import _detect_content_format
|
|
62
|
+
content_format = _detect_content_format(native_chat_template(model_source), default="string")
|
|
63
|
+
args += ["--chat-template-content-format", content_format]
|
|
64
|
+
|
|
65
|
+
|
|
41
66
|
def build_vllm_args(model_source: str, adapter_path: str | None, temp_dir: Path) -> list[str]:
|
|
42
67
|
args = [
|
|
43
68
|
"serve", model_source,
|
|
@@ -58,6 +83,8 @@ def build_vllm_args(model_source: str, adapter_path: str | None, temp_dir: Path)
|
|
|
58
83
|
]
|
|
59
84
|
if os.environ.get("TT_API_KEY"):
|
|
60
85
|
args += ["--middleware", "serve.BearerAuthMiddleware"]
|
|
86
|
+
if not is_certified_base_model(os.environ.get("TT_BASE_MODEL", "")):
|
|
87
|
+
args += ["--load-format", "safetensors"]
|
|
61
88
|
base = os.environ["TT_BASE_MODEL"]
|
|
62
89
|
# Upstream model-specific parsers, not a TT parser or model-size heuristic.
|
|
63
90
|
parser = {
|
|
@@ -81,14 +108,7 @@ def build_vllm_args(model_source: str, adapter_path: str | None, temp_dir: Path)
|
|
|
81
108
|
"path": adapter_path, "base_model_name": base_name})]
|
|
82
109
|
prompt = os.environ.get("TT_SYSTEM_PROMPT", "").strip()
|
|
83
110
|
if prompt:
|
|
84
|
-
|
|
85
|
-
template_file = source / "chat_template.jinja"
|
|
86
|
-
if template_file.is_file():
|
|
87
|
-
template = template_file.read_text(encoding="utf-8")
|
|
88
|
-
else:
|
|
89
|
-
template = json.loads((source / "tokenizer_config.json").read_text())["chat_template"]
|
|
90
|
-
if not isinstance(template, str):
|
|
91
|
-
raise ValueError("Serving requires an unambiguous string chat template.")
|
|
111
|
+
template = native_chat_template(model_source)
|
|
92
112
|
# Literal data, never prompt text interpolated as Jinja source. Preserve
|
|
93
113
|
# tool calls/results verbatim while merging leading system context.
|
|
94
114
|
prefix = "{%- set tt = namespace(system=" + json.dumps(prompt) + ", history=[]) -%}"
|
|
@@ -111,9 +131,14 @@ def build_vllm_args(model_source: str, adapter_path: str | None, temp_dir: Path)
|
|
|
111
131
|
def prepare_model_source() -> str:
|
|
112
132
|
base = os.environ["TT_BASE_MODEL"]
|
|
113
133
|
revision = os.environ.get("TT_BASE_MODEL_REVISION")
|
|
114
|
-
assert_certified_base_model_revision(base, revision, "Serving base model revision")
|
|
115
|
-
configure_hugging_face_cache(os.environ.get("HF_HOME"))
|
|
116
134
|
source = os.environ.get("TT_MODEL_SOURCE", base)
|
|
135
|
+
assert_certified_base_model_revision(
|
|
136
|
+
base,
|
|
137
|
+
revision,
|
|
138
|
+
"Serving base model revision",
|
|
139
|
+
local_source=source != base and Path(source).is_dir(),
|
|
140
|
+
)
|
|
141
|
+
configure_hugging_face_cache(os.environ.get("HF_HOME"))
|
|
117
142
|
if not Path(source).is_dir():
|
|
118
143
|
if source != base:
|
|
119
144
|
raise ValueError("Explicit serving snapshot is not an existing directory.")
|
|
@@ -143,6 +168,7 @@ def run_server() -> None:
|
|
|
143
168
|
model_source = prepare_model_source()
|
|
144
169
|
adapter = resolve_adapter_path(os.environ.get("TT_MODEL_ARTIFACT"), Path(temp))
|
|
145
170
|
args = build_vllm_args(model_source, adapter, Path(temp))
|
|
171
|
+
pin_native_content_format(args, model_source)
|
|
146
172
|
from vllm.entrypoints.cli.main import main
|
|
147
173
|
previous = sys.argv
|
|
148
174
|
try:
|
|
@@ -27,6 +27,7 @@ from model_contract import (
|
|
|
27
27
|
assert_certified_model_config,
|
|
28
28
|
chat_template_kwargs,
|
|
29
29
|
parse_lora_target_modules,
|
|
30
|
+
uncertified_load_kwargs,
|
|
30
31
|
)
|
|
31
32
|
from sft_data import IGNORE_INDEX, build_assistant_only_example
|
|
32
33
|
|
|
@@ -82,6 +83,7 @@ def assert_certified_request() -> None:
|
|
|
82
83
|
str(requested_model),
|
|
83
84
|
hp("base_model_revision"),
|
|
84
85
|
"Training base model revision",
|
|
86
|
+
local_source=Path(resolve_model_source()).is_dir(),
|
|
85
87
|
)
|
|
86
88
|
loader = hp("model_loader", "causal_lm")
|
|
87
89
|
if loader not in ("causal_lm", "image_text_to_text"):
|
|
@@ -199,6 +201,11 @@ def create_model_and_tokenizer(model_source: str) -> tuple[Any, Any, torch.dtype
|
|
|
199
201
|
raise ValueError(f"{hp('base_model', CERTIFIED_BASE_MODEL)} tokenizer has no EOS token")
|
|
200
202
|
if tokenizer.pad_token_id is None:
|
|
201
203
|
tokenizer.pad_token = tokenizer.eos_token
|
|
204
|
+
if not tokenizer.chat_template:
|
|
205
|
+
raise ValueError(
|
|
206
|
+
f"{hp('base_model', CERTIFIED_BASE_MODEL)} has no chat template; "
|
|
207
|
+
"choose an instruct or chat variant of the model"
|
|
208
|
+
)
|
|
202
209
|
|
|
203
210
|
dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
|
|
204
211
|
# Multimodal checkpoints whose text tower is not registered in the
|
|
@@ -212,6 +219,7 @@ def create_model_and_tokenizer(model_source: str) -> tuple[Any, Any, torch.dtype
|
|
|
212
219
|
model = model_class.from_pretrained(
|
|
213
220
|
model_source,
|
|
214
221
|
**source_kwargs,
|
|
222
|
+
**uncertified_load_kwargs(str(hp("base_model", CERTIFIED_BASE_MODEL))),
|
|
215
223
|
dtype=dtype,
|
|
216
224
|
device_map={"": torch.cuda.current_device()},
|
|
217
225
|
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tuned-tensor-decision"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
requires-python = ">=3.11"
|
|
5
|
+
dependencies = [
|
|
6
|
+
"huggingface-hub>=0.36.0",
|
|
7
|
+
"laya==0.3.22",
|
|
8
|
+
"numpy>=2.0.0",
|
|
9
|
+
"safetensors>=0.4.5",
|
|
10
|
+
"torch>=2.11.0",
|
|
11
|
+
"transformers>=4.48.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[tool.uv.sources]
|
|
15
|
+
torch = [
|
|
16
|
+
{ index = "pytorch-cu130", marker = "sys_platform == 'linux'" },
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[[tool.uv.index]]
|
|
20
|
+
name = "pytorch-cu130"
|
|
21
|
+
url = "https://download.pytorch.org/whl/cu130"
|
|
22
|
+
explicit = true
|
|
23
|
+
|
|
24
|
+
[tool.uv]
|
|
25
|
+
package = false
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Shared helpers for the typed decision-model engine (Laya-format checkpoints).
|
|
2
|
+
|
|
3
|
+
Node owns the behavior spec, splits, scoring and reports. This package only
|
|
4
|
+
loads a decision checkpoint, predicts labels for opaque IDs, and fine-tunes the
|
|
5
|
+
checkpoint's encoder and decision head on labelled rows.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import torch
|
|
17
|
+
|
|
18
|
+
CHECKPOINT_FILES = ("rl_agent_config.json", "model.safetensors", "tokenizer/*", "encoder/*")
|
|
19
|
+
QUESTION_ID = "decision"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_config(argv: list[str] | None = None) -> dict[str, Any]:
|
|
23
|
+
parser = argparse.ArgumentParser()
|
|
24
|
+
parser.add_argument("--config", required=True)
|
|
25
|
+
args = parser.parse_args(argv)
|
|
26
|
+
return json.loads(Path(args.config).read_text(encoding="utf-8"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def ensure_private_directory(path: str | Path) -> Path:
|
|
30
|
+
destination = Path(path)
|
|
31
|
+
destination.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
32
|
+
destination.chmod(0o700)
|
|
33
|
+
return destination
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def write_json(path: str | Path, value: Any) -> None:
|
|
37
|
+
write_text(path, json.dumps(value, indent=2) + "\n")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def write_jsonl(path: str | Path, rows: list[Any]) -> None:
|
|
41
|
+
write_text(path, "".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def write_text(path: str | Path, content: str) -> None:
|
|
45
|
+
destination = Path(path)
|
|
46
|
+
ensure_private_directory(destination.parent)
|
|
47
|
+
descriptor, temporary_name = tempfile.mkstemp(dir=destination.parent, prefix=f".{destination.name}.", suffix=".tmp")
|
|
48
|
+
temporary = Path(temporary_name)
|
|
49
|
+
try:
|
|
50
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as output:
|
|
51
|
+
os.fchmod(output.fileno(), 0o600)
|
|
52
|
+
output.write(content)
|
|
53
|
+
output.flush()
|
|
54
|
+
os.fsync(output.fileno())
|
|
55
|
+
os.replace(temporary, destination)
|
|
56
|
+
finally:
|
|
57
|
+
temporary.unlink(missing_ok=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def read_jsonl(path: str | Path) -> list[dict[str, Any]]:
|
|
61
|
+
rows = []
|
|
62
|
+
for number, line in enumerate(Path(path).read_text(encoding="utf-8").splitlines(), start=1):
|
|
63
|
+
if not line.strip():
|
|
64
|
+
continue
|
|
65
|
+
row = json.loads(line)
|
|
66
|
+
if not isinstance(row, dict):
|
|
67
|
+
raise ValueError(f"{path}:{number} must be a JSON object")
|
|
68
|
+
rows.append(row)
|
|
69
|
+
return rows
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def pick_device(requested: str | None) -> torch.device:
|
|
73
|
+
if requested and requested != "auto":
|
|
74
|
+
device = torch.device(requested)
|
|
75
|
+
if device.type == "cuda" and not torch.cuda.is_available():
|
|
76
|
+
raise RuntimeError("Decision device cuda was requested, but PyTorch cannot see a CUDA GPU.")
|
|
77
|
+
if device.type == "mps" and not torch.backends.mps.is_available():
|
|
78
|
+
raise RuntimeError("Decision device mps was requested, but PyTorch cannot use Apple MPS.")
|
|
79
|
+
return device
|
|
80
|
+
if torch.cuda.is_available():
|
|
81
|
+
return torch.device("cuda")
|
|
82
|
+
if torch.backends.mps.is_available():
|
|
83
|
+
return torch.device("mps")
|
|
84
|
+
return torch.device("cpu")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def option_labels(question: dict[str, Any]) -> list[str]:
|
|
88
|
+
"""Answer labels in the model's option order; these are the strings Node scores."""
|
|
89
|
+
kind = question["type"]
|
|
90
|
+
criteria = question.get("criteria")
|
|
91
|
+
if kind == "choice":
|
|
92
|
+
return [str(label) for label in (criteria.keys() if isinstance(criteria, dict) else criteria)]
|
|
93
|
+
if kind == "noul":
|
|
94
|
+
return ["false", "true"]
|
|
95
|
+
if kind == "score":
|
|
96
|
+
return [str(index) for index in range(len(criteria))]
|
|
97
|
+
raise ValueError(f"Unsupported decision type: {kind!r}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def label_index(question: dict[str, Any], output: str) -> int:
|
|
101
|
+
labels = option_labels(question)
|
|
102
|
+
normalized = output.strip().lower()
|
|
103
|
+
for index, label in enumerate(labels):
|
|
104
|
+
if label.strip().lower() == normalized:
|
|
105
|
+
return index
|
|
106
|
+
raise ValueError(f"Expected output {output!r} is not one of the decision labels {labels}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def resolve_checkpoint_dir(model: str, revision: str | None) -> Path:
|
|
110
|
+
"""Return a local checkpoint directory, downloading only the files a load needs."""
|
|
111
|
+
local = Path(model)
|
|
112
|
+
if local.is_dir():
|
|
113
|
+
return local
|
|
114
|
+
if local.is_absolute() or model.startswith(("./", "../")):
|
|
115
|
+
raise FileNotFoundError(f"Decision checkpoint directory not found: {model}")
|
|
116
|
+
from huggingface_hub import snapshot_download
|
|
117
|
+
|
|
118
|
+
return Path(snapshot_download(model, revision=revision, allow_patterns=list(CHECKPOINT_FILES)))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def load_agent(model: str, revision: str | None, device: torch.device):
|
|
122
|
+
from laya import Agent
|
|
123
|
+
|
|
124
|
+
checkpoint = resolve_checkpoint_dir(model, revision)
|
|
125
|
+
# Load the resolved directory so Hub and local checkpoints take the same path.
|
|
126
|
+
agent = Agent(str(checkpoint), device=str(device))
|
|
127
|
+
return agent, checkpoint
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Predict one typed decision per input row.
|
|
2
|
+
|
|
3
|
+
The config names only opaque row IDs and inputs. Reference labels stay in Node,
|
|
4
|
+
which joins these predictions to trusted expected outputs before scoring.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from common import QUESTION_ID, load_agent, load_config, option_labels, pick_device, read_jsonl, write_json, write_jsonl
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def answer_distribution(question: dict[str, Any], answer: dict[str, Any]) -> dict[str, float]:
|
|
17
|
+
labels = option_labels(question)
|
|
18
|
+
if question["type"] == "noul":
|
|
19
|
+
p_true = float(answer["noul"])
|
|
20
|
+
return {"false": 1.0 - p_true, "true": p_true}
|
|
21
|
+
# Laya keys choice probabilities by label and score probabilities by level index.
|
|
22
|
+
return {label: float(answer["probabilities"][label]) for label in labels}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def predict_rows(agent, question: dict[str, Any], rows: list[dict[str, Any]], batch_size: int) -> list[dict[str, Any]]:
|
|
26
|
+
predictions: list[dict[str, Any]] = []
|
|
27
|
+
for start in range(0, len(rows), batch_size):
|
|
28
|
+
batch = rows[start:start + batch_size]
|
|
29
|
+
began = time.perf_counter()
|
|
30
|
+
results = agent.predict_batch([row["input"] for row in batch], {QUESTION_ID: question}, batch_size=batch_size)
|
|
31
|
+
latency_ms = int(round((time.perf_counter() - began) * 1000 / max(1, len(batch))))
|
|
32
|
+
for row, result in zip(batch, results):
|
|
33
|
+
distribution = answer_distribution(question, result["answers"][QUESTION_ID])
|
|
34
|
+
labels = list(distribution)
|
|
35
|
+
values = np.array([distribution[label] for label in labels])
|
|
36
|
+
predictions.append({
|
|
37
|
+
"id": row["id"],
|
|
38
|
+
"prediction": labels[int(values.argmax())],
|
|
39
|
+
"probabilities": {label: round(float(value), 6) for label, value in distribution.items()},
|
|
40
|
+
"confidence": round(float(values.max()), 6),
|
|
41
|
+
"latency_ms": latency_ms,
|
|
42
|
+
})
|
|
43
|
+
return predictions
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main() -> None:
|
|
47
|
+
config = load_config()
|
|
48
|
+
device = pick_device(config.get("device"))
|
|
49
|
+
rows = read_jsonl(config["inputs_path"])
|
|
50
|
+
for row in rows:
|
|
51
|
+
if set(row) != {"id", "input"}:
|
|
52
|
+
raise ValueError("Decision evaluation rows must contain only id and input.")
|
|
53
|
+
began = time.perf_counter()
|
|
54
|
+
agent, checkpoint = load_agent(config["model"], config.get("revision"), device)
|
|
55
|
+
load_seconds = time.perf_counter() - began
|
|
56
|
+
predictions = predict_rows(agent, config["question"], rows, int(config.get("batch_size", 16)))
|
|
57
|
+
write_jsonl(config["output_path"], predictions)
|
|
58
|
+
write_json(config["metrics_path"], {
|
|
59
|
+
"ok": True,
|
|
60
|
+
"model": config["model"],
|
|
61
|
+
"revision": config.get("revision"),
|
|
62
|
+
"checkpoint_dir": str(checkpoint),
|
|
63
|
+
"device": str(agent.device),
|
|
64
|
+
"rows": len(predictions),
|
|
65
|
+
"load_seconds": round(load_seconds, 3),
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
main()
|