@tuned-tensor/cli 0.20.0 → 0.22.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.
@@ -0,0 +1,116 @@
1
+ """Prefetch the dataset files a behavior spec trains on from the Hugging Face Hub.
2
+
3
+ Mirrors prefetch.py for models: the files land in the shared Hugging Face cache
4
+ (``<HF_HOME>/hub/datasets--org--name``), the revision is pinned to an immutable
5
+ commit, and every requested file is checked against its content-addressed blob.
6
+ Only the split files named in the spec are downloaded, never the whole repo.
7
+ """
8
+ import argparse
9
+ import json
10
+ import re
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from hub_download import configure_download_environment, download_snapshot, verify_blob_digest
15
+ from prefetch import configure_hugging_face_cache, write_json
16
+
17
+ 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}")
18
+ COMMIT_SHA = re.compile(r"[0-9a-fA-F]{40}")
19
+
20
+
21
+ def validate_repo_path(value: Any) -> str:
22
+ if not isinstance(value, str) or not value or value.startswith(("/", "\\")):
23
+ raise ValueError(f"Dataset file must be a repository-relative path: {value!r}")
24
+ parts = re.split(r"[/\\]", value)
25
+ if any(part in ("", ".", "..") for part in parts) or re.match(r"^[A-Za-z]:", value):
26
+ raise ValueError(f"Dataset file must be a repository-relative path: {value!r}")
27
+ return "/".join(parts)
28
+
29
+
30
+ def verify_dataset_files(snapshot: Path, files: dict[str, str]) -> dict[str, dict[str, Any]]:
31
+ """Check each split file is a complete cache entry inside the dataset repo.
32
+
33
+ Normal caches link snapshot files to content-addressed blobs, which are
34
+ checked against their digest. Caches without symlink support (Windows
35
+ without Developer Mode) hold plain copies; those are contained and hashed.
36
+ """
37
+ repository = snapshot.parent.parent.resolve()
38
+ verified: dict[str, dict[str, Any]] = {}
39
+ for split, relative in files.items():
40
+ path = snapshot / relative
41
+ if not path.is_file():
42
+ raise ValueError(f"Dataset file {relative} ({split}) is missing from snapshot {snapshot}")
43
+ target = path.resolve()
44
+ if repository not in target.parents:
45
+ raise ValueError(f"Dataset file {relative} escapes the Hugging Face dataset cache: {target}")
46
+ size = target.stat().st_size
47
+ if size == 0:
48
+ raise ValueError(f"Dataset file {relative} ({split}) is empty")
49
+ _, sha256 = verify_blob_digest(path)
50
+ verified[split] = {
51
+ "path": relative,
52
+ "local_path": str(path),
53
+ "size_bytes": size,
54
+ "sha256": sha256,
55
+ }
56
+ return verified
57
+
58
+
59
+ def main() -> None:
60
+ parser = argparse.ArgumentParser(description="Prefetch Hugging Face dataset files for a TT behavior spec.")
61
+ parser.add_argument("--input", required=True)
62
+ parser.add_argument("--output", required=True)
63
+ args = parser.parse_args()
64
+
65
+ payload = json.loads(Path(args.input).read_text(encoding="utf-8"))
66
+ repo = str(payload["repo"])
67
+ if not HUGGING_FACE_REPO_ID.fullmatch(repo):
68
+ raise ValueError(f"Dataset repo must be a Hugging Face id like org/name: {repo!r}")
69
+ revision = payload.get("revision")
70
+ if revision is not None and not COMMIT_SHA.fullmatch(str(revision)):
71
+ raise ValueError("Dataset revision must be a 40-character Hugging Face commit SHA")
72
+ raw_files = payload.get("files")
73
+ if not isinstance(raw_files, dict) or not raw_files:
74
+ raise ValueError("Dataset prefetch requires at least one split file")
75
+ files = {str(split): validate_repo_path(path) for split, path in raw_files.items()}
76
+
77
+ configure_hugging_face_cache(payload.get("model_cache"))
78
+ configure_download_environment()
79
+ from huggingface_hub import constants
80
+
81
+ local_only = bool(payload.get("local_files_only", False))
82
+ action = "Verifying cached" if local_only else "Prefetching"
83
+ print(f"{action} dataset {repo} in {constants.HF_HUB_CACHE}...", flush=True)
84
+ snapshot_path, commit = download_snapshot(
85
+ repo_id=repo,
86
+ repo_type="dataset",
87
+ revision=revision,
88
+ allow_patterns=sorted(set(files.values())),
89
+ local_files_only=local_only,
90
+ label="dataset_prefetch",
91
+ )
92
+ snapshot = Path(snapshot_path)
93
+ if not COMMIT_SHA.fullmatch(snapshot.name):
94
+ raise ValueError("Hugging Face dataset snapshot did not resolve to a 40-character immutable commit SHA")
95
+ if revision and snapshot.name.lower() != str(revision).lower():
96
+ raise ValueError(f"Hugging Face returned dataset revision {snapshot.name}, not requested revision {revision}")
97
+ print(f"Verifying dataset files under {snapshot}...", flush=True)
98
+ verified = verify_dataset_files(snapshot, files)
99
+
100
+ write_json(args.output, {
101
+ "ok": True,
102
+ "repo": repo,
103
+ "repo_type": "dataset",
104
+ "requested_revision": revision,
105
+ "snapshot_revision": snapshot.name.lower(),
106
+ "snapshot_path": str(snapshot),
107
+ "hf_home": str(constants.HF_HOME),
108
+ "hub_cache": str(constants.HF_HUB_CACHE),
109
+ "files": verified,
110
+ "size_bytes": sum(item["size_bytes"] for item in verified.values()),
111
+ "commit": commit,
112
+ })
113
+
114
+
115
+ if __name__ == "__main__":
116
+ main()
@@ -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,9 @@ 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)
112
+ from vllm.renderers.hf import _detect_content_format
113
+ content_format = _detect_content_format(template, default="string")
92
114
  # Literal data, never prompt text interpolated as Jinja source. Preserve
93
115
  # tool calls/results verbatim while merging leading system context.
94
116
  prefix = "{%- set tt = namespace(system=" + json.dumps(prompt) + ", history=[]) -%}"
@@ -101,7 +123,9 @@ def build_vllm_args(model_source: str, adapter_path: str | None, temp_dir: Path)
101
123
  prefix += "{%- set tt.system = tt.system + '\\n\\n' + part.text -%}"
102
124
  prefix += "{%- endif -%}{%- endfor -%}{%- endif -%}"
103
125
  prefix += "{%- else -%}{%- set tt.history = tt.history + [message] -%}{%- endif -%}{%- endfor -%}"
104
- prefix += "{%- set messages = [{'role': 'system', 'content': tt.system}] + tt.history -%}"
126
+ # Match the ORIGINAL native template's expected content representation.
127
+ system_content = "[{'type': 'text', 'text': tt.system}]" if content_format == "openai" else "tt.system"
128
+ prefix += "{%- set messages = [{'role': 'system', 'content': " + system_content + "}] + tt.history -%}"
105
129
  path = temp_dir / "chat-template.jinja"
106
130
  path.write_text(prefix + template, encoding="utf-8")
107
131
  args += ["--chat-template", str(path)]
@@ -111,9 +135,14 @@ def build_vllm_args(model_source: str, adapter_path: str | None, temp_dir: Path)
111
135
  def prepare_model_source() -> str:
112
136
  base = os.environ["TT_BASE_MODEL"]
113
137
  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
138
  source = os.environ.get("TT_MODEL_SOURCE", base)
139
+ assert_certified_base_model_revision(
140
+ base,
141
+ revision,
142
+ "Serving base model revision",
143
+ local_source=source != base and Path(source).is_dir(),
144
+ )
145
+ configure_hugging_face_cache(os.environ.get("HF_HOME"))
117
146
  if not Path(source).is_dir():
118
147
  if source != base:
119
148
  raise ValueError("Explicit serving snapshot is not an existing directory.")
@@ -143,6 +172,7 @@ def run_server() -> None:
143
172
  model_source = prepare_model_source()
144
173
  adapter = resolve_adapter_path(os.environ.get("TT_MODEL_ARTIFACT"), Path(temp))
145
174
  args = build_vllm_args(model_source, adapter, Path(temp))
175
+ pin_native_content_format(args, model_source)
146
176
  from vllm.entrypoints.cli.main import main
147
177
  previous = sys.argv
148
178
  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()
@@ -0,0 +1,181 @@
1
+ """Fine-tune a Laya-format decision checkpoint on labelled rows for one typed question.
2
+
3
+ The encoder and decision head are trained with cross-entropy over the option
4
+ markers, a strictly proper scoring rule, so the tuned probabilities remain a
5
+ calibration target rather than only an argmax. Options are shuffled per example
6
+ so the head learns option meaning instead of option position. The output
7
+ directory is a complete checkpoint that `laya.load(<dir>)` accepts.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import random
13
+ import shutil
14
+ import time
15
+ from contextlib import nullcontext
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import torch
20
+ import torch.nn.functional as F
21
+ from laya.common import QTYPES, build_sequence, collate_items
22
+ from safetensors.torch import save_file
23
+
24
+ from common import (
25
+ ensure_private_directory,
26
+ label_index,
27
+ load_agent,
28
+ load_config,
29
+ option_labels,
30
+ pick_device,
31
+ read_jsonl,
32
+ write_json,
33
+ )
34
+
35
+
36
+ def internal_question(question: dict[str, Any]) -> dict[str, Any]:
37
+ """Laya's internal question form; mirrors `Agent._to_internal` for the fields TT emits."""
38
+ criteria = question.get("criteria")
39
+ if question["type"] == "choice" and isinstance(criteria, list):
40
+ criteria = {label: None for label in criteria}
41
+ return {"t": question["type"], "ins": question["instructions"], "crit": criteria}
42
+
43
+
44
+ def encode_example(tok, cfg: dict[str, Any], question: dict[str, Any], text: str, label: int, order: list[int]) -> dict[str, Any]:
45
+ ids, markers = build_sequence(
46
+ tok,
47
+ text,
48
+ question,
49
+ cfg.get("max_len", 512),
50
+ cfg.get("head_max_len", 192),
51
+ option_order=order,
52
+ )
53
+ if len(markers) != len(order):
54
+ raise ValueError("A decision option marker was truncated; shorten the instructions or option descriptions.")
55
+ # Slot s shows option order[s], so the target is the slot holding the labelled option.
56
+ return {"ids": ids, "markers": markers, "qtype": QTYPES[question["t"]], "label": order.index(label)}
57
+
58
+
59
+ def autocast(device: torch.device):
60
+ if device.type == "cuda":
61
+ dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
62
+ return torch.autocast("cuda", dtype=dtype)
63
+ return nullcontext()
64
+
65
+
66
+ def save_checkpoint(model, source: Path, output: Path, cfg: dict[str, Any], provenance: dict[str, Any]) -> None:
67
+ ensure_private_directory(output)
68
+ for name in ("tokenizer", "encoder"):
69
+ if (source / name).is_dir():
70
+ shutil.copytree(source / name, output / name, dirs_exist_ok=True)
71
+ state = {key: value.detach().to("cpu").contiguous() for key, value in model.state_dict().items()}
72
+ # The base checkpoint's fitted temperatures describe its logits, not the tuned ones.
73
+ state["temperature"] = torch.ones_like(state["temperature"])
74
+ save_file(state, str(output / "model.safetensors"))
75
+ tuned = {key: value for key, value in cfg.items() if key != "temperature_by_options"}
76
+ tuned["temperature"] = [1.0, 1.0, 1.0]
77
+ tuned["tuned_tensor"] = provenance
78
+ (output / "rl_agent_config.json").write_text(json.dumps(tuned, indent=2) + "\n", encoding="utf-8")
79
+ # Copied tokenizer/encoder files keep the cache's modes; keep the whole checkpoint owner-only.
80
+ for path in output.rglob("*"):
81
+ path.chmod(0o700 if path.is_dir() else 0o600)
82
+
83
+
84
+ def main() -> None:
85
+ config = load_config()
86
+ seed = int(config.get("seed", 0))
87
+ random.seed(seed)
88
+ torch.manual_seed(seed)
89
+ device = pick_device(config.get("device"))
90
+ question = config["question"]
91
+ labels = option_labels(question)
92
+ rows = read_jsonl(config["train_path"])
93
+ if not rows:
94
+ raise ValueError("Decision training needs at least one labelled row.")
95
+ targets = [label_index(question, str(row["output"])) for row in rows]
96
+
97
+ agent, checkpoint = load_agent(config["model"], config.get("revision"), device)
98
+ model = agent.model
99
+ if config.get("freeze_encoder", False):
100
+ for parameter in model.encoder.parameters():
101
+ parameter.requires_grad_(False)
102
+ trainable = [parameter for parameter in model.parameters() if parameter.requires_grad]
103
+ optimizer = torch.optim.AdamW(trainable, lr=float(config.get("learning_rate", 2e-5)), weight_decay=0.01)
104
+ epochs = int(config.get("epochs", 3))
105
+ batch_size = int(config.get("batch_size", 8))
106
+ shuffle_options = bool(config.get("shuffle_options", True))
107
+ steps_per_epoch = (len(rows) + batch_size - 1) // batch_size
108
+ total_steps = epochs * steps_per_epoch
109
+ warmup = max(1, total_steps // 10)
110
+ scheduler = torch.optim.lr_scheduler.LambdaLR(
111
+ optimizer,
112
+ lambda step: min(1.0, (step + 1) / warmup) * max(0.0, (total_steps - step) / max(1, total_steps - warmup + 1)),
113
+ )
114
+ q = internal_question(question)
115
+ pad_id = agent.tok.pad_token_id if agent.tok.pad_token_id is not None else 0
116
+
117
+ model.train()
118
+ history: list[dict[str, Any]] = []
119
+ began = time.perf_counter()
120
+ step = 0
121
+ for epoch in range(epochs):
122
+ order_of_rows = list(range(len(rows)))
123
+ random.shuffle(order_of_rows)
124
+ epoch_loss = 0.0
125
+ for start in range(0, len(rows), batch_size):
126
+ items = []
127
+ for index in order_of_rows[start:start + batch_size]:
128
+ order = list(range(len(labels)))
129
+ if shuffle_options:
130
+ random.shuffle(order)
131
+ items.append([encode_example(agent.tok, agent.cfg, q, str(rows[index]["input"]), targets[index], order)])
132
+ batch = collate_items(items, pad_id)
133
+ with autocast(device):
134
+ logits, _ = model(
135
+ batch["input_ids"].to(device),
136
+ batch["attention_mask"].to(device),
137
+ batch["marker_pos"].to(device),
138
+ batch["marker_mask"].to(device),
139
+ batch["qtype"].to(device),
140
+ )
141
+ loss = F.cross_entropy(logits.float(), batch["label"].to(device))
142
+ optimizer.zero_grad(set_to_none=True)
143
+ loss.backward()
144
+ torch.nn.utils.clip_grad_norm_(trainable, 1.0)
145
+ optimizer.step()
146
+ scheduler.step()
147
+ step += 1
148
+ epoch_loss += float(loss.item()) * len(items)
149
+ print(json.dumps({"event": "step", "epoch": epoch + 1, "step": step, "total_steps": total_steps, "loss": round(float(loss.item()), 6)}), flush=True)
150
+ history.append({"epoch": epoch + 1, "loss": round(epoch_loss / len(rows), 6)})
151
+
152
+ model.eval()
153
+ seconds = time.perf_counter() - began
154
+ provenance = {
155
+ "base_model": config["model"],
156
+ "base_revision": config.get("revision"),
157
+ "question_type": question["type"],
158
+ "labels": labels,
159
+ "train_rows": len(rows),
160
+ "epochs": epochs,
161
+ "learning_rate": float(config.get("learning_rate", 2e-5)),
162
+ "freeze_encoder": bool(config.get("freeze_encoder", False)),
163
+ "seed": seed,
164
+ }
165
+ output = Path(config["output_dir"])
166
+ save_checkpoint(model, checkpoint, output, agent.cfg, provenance)
167
+ write_json(config["metrics_path"], {
168
+ "ok": True,
169
+ "device": str(device),
170
+ "train_rows": len(rows),
171
+ "epochs": epochs,
172
+ "steps": step,
173
+ "train_seconds": round(seconds, 3),
174
+ "loss_history": history,
175
+ "final_loss": history[-1]["loss"],
176
+ "trainable_parameters": sum(parameter.numel() for parameter in trainable),
177
+ })
178
+
179
+
180
+ if __name__ == "__main__":
181
+ main()