@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.
@@ -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
- if not Path(base_model).exists() and base_model not in CERTIFIED_BASE_MODELS:
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 {base_model!r}"
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(base_model, revision, "Evaluation 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] = dict(source_kwargs)
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
- if model_id not in CERTIFIED_BASE_MODELS:
131
- supported = ", ".join(CERTIFIED_BASE_MODELS)
132
- raise ValueError(f"{label} must be one of {supported}; got {model_id!r}")
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(base_model: str, revision: str | None, label: str = "base model revision") -> None:
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 assert_certified_base_model_revision, assert_certified_model_config
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
- source = Path(model_source)
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()