@tuned-tensor/local 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +161 -0
  3. package/README.md +242 -0
  4. package/dist/artifacts.d.ts +35 -0
  5. package/dist/artifacts.js +56 -0
  6. package/dist/artifacts.js.map +1 -0
  7. package/dist/contracts.d.ts +471 -0
  8. package/dist/contracts.js +253 -0
  9. package/dist/contracts.js.map +1 -0
  10. package/dist/dataset.d.ts +12 -0
  11. package/dist/dataset.js +60 -0
  12. package/dist/dataset.js.map +1 -0
  13. package/dist/docker-training.d.ts +8 -0
  14. package/dist/docker-training.js +123 -0
  15. package/dist/docker-training.js.map +1 -0
  16. package/dist/doctor.d.ts +7 -0
  17. package/dist/doctor.js +74 -0
  18. package/dist/doctor.js.map +1 -0
  19. package/dist/evaluation.d.ts +23 -0
  20. package/dist/evaluation.js +318 -0
  21. package/dist/evaluation.js.map +1 -0
  22. package/dist/index.d.ts +14 -0
  23. package/dist/index.js +316 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/local-project.d.ts +22 -0
  26. package/dist/local-project.js +74 -0
  27. package/dist/local-project.js.map +1 -0
  28. package/dist/model-registry.d.ts +18 -0
  29. package/dist/model-registry.js +151 -0
  30. package/dist/model-registry.js.map +1 -0
  31. package/dist/openrouter.d.ts +16 -0
  32. package/dist/openrouter.js +39 -0
  33. package/dist/openrouter.js.map +1 -0
  34. package/dist/orchestrator.d.ts +14 -0
  35. package/dist/orchestrator.js +151 -0
  36. package/dist/orchestrator.js.map +1 -0
  37. package/dist/process-training.d.ts +8 -0
  38. package/dist/process-training.js +135 -0
  39. package/dist/process-training.js.map +1 -0
  40. package/dist/server.d.ts +9 -0
  41. package/dist/server.js +211 -0
  42. package/dist/server.js.map +1 -0
  43. package/dist/store.d.ts +98 -0
  44. package/dist/store.js +327 -0
  45. package/dist/store.js.map +1 -0
  46. package/docs/architecture.md +109 -0
  47. package/docs/spark.md +86 -0
  48. package/examples/local-runner.json +14 -0
  49. package/examples/smoke-run-request.json +34 -0
  50. package/package.json +40 -0
  51. package/training/sft-local/pyproject.toml +24 -0
  52. package/training/sft-local/src/evaluate.py +177 -0
  53. package/training/sft-local/src/train.py +233 -0
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import tarfile
7
+ import time
8
+ from pathlib import Path
9
+ from tempfile import TemporaryDirectory
10
+ from typing import Any
11
+
12
+ import torch
13
+ from peft import PeftModel
14
+ from transformers import AutoModelForCausalLM, AutoTokenizer
15
+
16
+
17
+ def load_json(path: Path) -> dict[str, Any]:
18
+ return json.loads(path.read_text())
19
+
20
+
21
+ def strip_file_uri(value: str | None) -> str | None:
22
+ if not value:
23
+ return value
24
+ if value.startswith("file://"):
25
+ return value[7:]
26
+ return value
27
+
28
+
29
+ def resolve_adapter_path(value: str | None, tmp: Path) -> str | None:
30
+ path_value = strip_file_uri(value)
31
+ if not path_value:
32
+ return None
33
+ path = Path(path_value)
34
+ if path.is_file() and path.name.endswith(".tar.gz"):
35
+ extracted = tmp / "adapter"
36
+ extracted.mkdir(parents=True, exist_ok=True)
37
+ with tarfile.open(path, "r:gz") as tar:
38
+ for member in tar.getmembers():
39
+ destination = (extracted / member.name).resolve()
40
+ if not str(destination).startswith(str(extracted.resolve())):
41
+ raise ValueError(f"Unsafe archive member: {member.name}")
42
+ tar.extractall(extracted)
43
+ candidates = [candidate for candidate in extracted.rglob("adapter_config.json")]
44
+ if candidates:
45
+ return str(candidates[0].parent)
46
+ directories = [candidate for candidate in extracted.iterdir() if candidate.is_dir()]
47
+ return str(directories[0] if len(directories) == 1 else extracted)
48
+ return str(path)
49
+
50
+
51
+ def fallback_prompt(system: str, prompt: str) -> str:
52
+ return f"system: {system}\nuser: {prompt}\nassistant:"
53
+
54
+
55
+ def format_prompt(tokenizer: Any, system: str, prompt: str) -> str:
56
+ messages = [
57
+ {"role": "system", "content": system},
58
+ {"role": "user", "content": prompt},
59
+ ]
60
+ try:
61
+ return tokenizer.apply_chat_template(
62
+ messages,
63
+ tokenize=False,
64
+ add_generation_prompt=True,
65
+ )
66
+ except Exception:
67
+ return fallback_prompt(system, prompt)
68
+
69
+
70
+ def resolve_device(device: str) -> str:
71
+ if device == "auto":
72
+ if torch.cuda.is_available():
73
+ return "cuda"
74
+ if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
75
+ return "mps"
76
+ return "cpu"
77
+ return device
78
+
79
+
80
+ def load_model(payload: dict[str, Any], adapter_path: str | None):
81
+ base_model = payload["base_model"]
82
+ trust_remote_code = bool(payload.get("trust_remote_code", True))
83
+ device = resolve_device(str(payload.get("device", "auto")))
84
+ token = os.getenv("HF_TOKEN")
85
+
86
+ tokenizer = AutoTokenizer.from_pretrained(
87
+ base_model,
88
+ trust_remote_code=trust_remote_code,
89
+ token=token,
90
+ )
91
+ if tokenizer.pad_token is None:
92
+ tokenizer.pad_token = tokenizer.eos_token
93
+
94
+ dtype = None
95
+ if device == "cuda":
96
+ dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
97
+
98
+ kwargs: dict[str, Any] = {
99
+ "trust_remote_code": trust_remote_code,
100
+ "token": token,
101
+ }
102
+ if dtype is not None:
103
+ kwargs["torch_dtype"] = dtype
104
+ if device == "cuda":
105
+ kwargs["device_map"] = "auto"
106
+
107
+ model = AutoModelForCausalLM.from_pretrained(base_model, **kwargs)
108
+ if adapter_path:
109
+ model = PeftModel.from_pretrained(model, adapter_path)
110
+ if device != "cuda":
111
+ model = model.to(device)
112
+ model.eval()
113
+ return model, tokenizer, device
114
+
115
+
116
+ def generate_one(model: Any, tokenizer: Any, device: str, system: str, example: dict[str, Any], generation: dict[str, Any]) -> dict[str, Any]:
117
+ prompt = str(example["input"])
118
+ expected = str(example["output"])
119
+ formatted = format_prompt(tokenizer, system, prompt)
120
+ inputs = tokenizer(formatted, return_tensors="pt")
121
+ target_device = next(model.parameters()).device
122
+ inputs = {key: value.to(target_device) for key, value in inputs.items()}
123
+ started = time.perf_counter()
124
+ with torch.no_grad():
125
+ generated = model.generate(
126
+ **inputs,
127
+ max_new_tokens=int(generation.get("max_new_tokens", 256)),
128
+ do_sample=float(generation.get("temperature", 0)) > 0,
129
+ temperature=max(float(generation.get("temperature", 0)), 1e-5),
130
+ top_p=float(generation.get("top_p", 1)),
131
+ pad_token_id=tokenizer.eos_token_id,
132
+ )
133
+ latency_ms = max(0, round((time.perf_counter() - started) * 1000))
134
+ input_length = int(inputs["input_ids"].shape[-1])
135
+ output_tokens = generated[0][input_length:]
136
+ actual = tokenizer.decode(output_tokens, skip_special_tokens=True).strip()
137
+ return {
138
+ "prompt": prompt,
139
+ "expected": expected,
140
+ "actual": actual,
141
+ "latency_ms": latency_ms,
142
+ }
143
+
144
+
145
+ def main() -> None:
146
+ parser = argparse.ArgumentParser()
147
+ parser.add_argument("--input", required=True)
148
+ parser.add_argument("--output", required=True)
149
+ args = parser.parse_args()
150
+
151
+ payload = load_json(Path(args.input))
152
+ if payload.get("model_cache"):
153
+ os.environ["HF_HOME"] = str(payload["model_cache"])
154
+
155
+ with TemporaryDirectory() as tmp_dir:
156
+ adapter_path = resolve_adapter_path(payload.get("adapter_path"), Path(tmp_dir))
157
+ model, tokenizer, device = load_model(payload, adapter_path)
158
+ generation = payload.get("generation", {})
159
+ results = [
160
+ generate_one(model, tokenizer, device, str(payload.get("system", "")), example, generation)
161
+ for example in payload.get("examples", [])
162
+ ]
163
+
164
+ output = {
165
+ "provider": "transformers",
166
+ "model_id": payload.get("model_id"),
167
+ "base_model": payload.get("base_model"),
168
+ "adapter_path": payload.get("adapter_path"),
169
+ "generation_config": generation,
170
+ "results": results,
171
+ }
172
+ Path(args.output).parent.mkdir(parents=True, exist_ok=True)
173
+ Path(args.output).write_text(json.dumps(output, indent=2))
174
+
175
+
176
+ if __name__ == "__main__":
177
+ main()
@@ -0,0 +1,233 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tarfile
6
+ import time
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from tempfile import TemporaryDirectory
10
+ from typing import Any
11
+
12
+ import torch
13
+ from peft import LoraConfig, get_peft_model
14
+ from torch.utils.data import Dataset
15
+ from transformers import (
16
+ AutoModelForCausalLM,
17
+ AutoTokenizer,
18
+ DataCollatorForLanguageModeling,
19
+ Trainer,
20
+ TrainingArguments,
21
+ )
22
+
23
+
24
+ TRAINING_DIR = Path(os.environ.get("SM_CHANNEL_TRAINING", "/opt/ml/input/data/training"))
25
+ BASE_MODEL_DIR = Path(os.environ.get("SM_CHANNEL_BASE_MODEL", "/opt/ml/input/data/base_model"))
26
+ HYPERPARAMETERS_PATH = Path(
27
+ os.environ.get("TT_HYPERPARAMETERS_PATH", "/opt/ml/input/config/hyperparameters.json")
28
+ )
29
+ MODEL_DIR = Path(os.environ.get("SM_MODEL_DIR", "/opt/ml/model"))
30
+ OUTPUT_DIR = Path(os.environ.get("SM_OUTPUT_DIR", "/opt/ml/output"))
31
+
32
+
33
+ def load_hyperparameters() -> dict[str, str]:
34
+ if not HYPERPARAMETERS_PATH.is_file():
35
+ return {}
36
+ raw = json.loads(HYPERPARAMETERS_PATH.read_text())
37
+ return {str(key): str(value) for key, value in raw.items()}
38
+
39
+
40
+ HP = load_hyperparameters()
41
+
42
+
43
+ def hp(name: str, default: str | None = None) -> str | None:
44
+ value = os.getenv(f"SM_HP_{name.upper()}", HP.get(name, default))
45
+ if value is None:
46
+ return None
47
+ value = str(value).strip()
48
+ if len(value) >= 2 and value[0] == value[-1] == '"':
49
+ return value[1:-1]
50
+ return value
51
+
52
+
53
+ def hp_int(name: str, default: int) -> int:
54
+ return int(hp(name, str(default)) or default)
55
+
56
+
57
+ def hp_float(name: str, default: float) -> float:
58
+ return float(hp(name, str(default)) or default)
59
+
60
+
61
+ def hp_bool(name: str, default: bool) -> bool:
62
+ return (hp(name, str(default)) or "").lower() in {"1", "true", "yes", "y"}
63
+
64
+
65
+ def load_rows() -> list[dict[str, Any]]:
66
+ rows: list[dict[str, Any]] = []
67
+ for path in sorted(TRAINING_DIR.rglob("*.jsonl")):
68
+ for line_number, line in enumerate(path.read_text().splitlines(), start=1):
69
+ if not line.strip():
70
+ continue
71
+ row = json.loads(line)
72
+ if "messages" not in row:
73
+ raise ValueError(f"{path}:{line_number} missing messages")
74
+ rows.append(row)
75
+ if not rows:
76
+ raise ValueError(f"No chat JSONL rows found under {TRAINING_DIR}")
77
+ return rows
78
+
79
+
80
+ def resolve_model_source() -> str:
81
+ archive = BASE_MODEL_DIR / "model.tar.gz"
82
+ if archive.is_file():
83
+ extracted = Path("/tmp/base-model")
84
+ extracted.mkdir(parents=True, exist_ok=True)
85
+ with tarfile.open(archive, "r:gz") as tar:
86
+ tar.extractall(extracted)
87
+ candidates = [path for path in extracted.iterdir() if path.is_dir()]
88
+ return str(candidates[0] if len(candidates) == 1 else extracted)
89
+ return hp("base_model") or "Qwen/Qwen3.5-2B"
90
+
91
+
92
+ def row_to_text(tokenizer: Any, row: dict[str, Any]) -> str:
93
+ messages = row["messages"]
94
+ try:
95
+ return tokenizer.apply_chat_template(
96
+ messages,
97
+ tokenize=False,
98
+ add_generation_prompt=False,
99
+ )
100
+ except Exception:
101
+ parts: list[str] = []
102
+ for message in messages:
103
+ role = message.get("role", "user")
104
+ content = message.get("content", "")
105
+ if not isinstance(content, str):
106
+ content = json.dumps(content, ensure_ascii=False)
107
+ parts.append(f"{role}: {content}")
108
+ return "\n".join(parts)
109
+
110
+
111
+ class TextDataset(Dataset[dict[str, torch.Tensor]]):
112
+ def __init__(self, texts: list[str], tokenizer: Any, max_length: int):
113
+ self.examples = [
114
+ tokenizer(
115
+ text,
116
+ truncation=True,
117
+ max_length=max_length,
118
+ padding=False,
119
+ return_tensors=None,
120
+ )
121
+ for text in texts
122
+ ]
123
+
124
+ def __len__(self) -> int:
125
+ return len(self.examples)
126
+
127
+ def __getitem__(self, index: int) -> dict[str, torch.Tensor]:
128
+ item = self.examples[index]
129
+ return {
130
+ "input_ids": torch.tensor(item["input_ids"], dtype=torch.long),
131
+ "attention_mask": torch.tensor(item["attention_mask"], dtype=torch.long),
132
+ }
133
+
134
+
135
+ def create_model_and_tokenizer(model_source: str):
136
+ trust_remote_code = hp_bool("trust_remote_code", True)
137
+ token = os.getenv("HF_TOKEN")
138
+ tokenizer = AutoTokenizer.from_pretrained(
139
+ model_source,
140
+ trust_remote_code=trust_remote_code,
141
+ token=token,
142
+ )
143
+ if tokenizer.pad_token is None:
144
+ tokenizer.pad_token = tokenizer.eos_token
145
+
146
+ dtype = torch.bfloat16 if torch.cuda.is_available() and torch.cuda.is_bf16_supported() else torch.float16
147
+ model = AutoModelForCausalLM.from_pretrained(
148
+ model_source,
149
+ trust_remote_code=trust_remote_code,
150
+ token=token,
151
+ torch_dtype=dtype if torch.cuda.is_available() else None,
152
+ device_map="auto" if torch.cuda.is_available() else None,
153
+ )
154
+ model.config.use_cache = False
155
+ return model, tokenizer
156
+
157
+
158
+ def maybe_apply_lora(model: Any):
159
+ rank = hp_int("lora_rank", 16)
160
+ alpha = hp_int("lora_alpha", 32)
161
+ dropout = hp_float("lora_dropout", 0.05)
162
+ config = LoraConfig(
163
+ r=rank,
164
+ lora_alpha=alpha,
165
+ lora_dropout=dropout,
166
+ bias="none",
167
+ task_type="CAUSAL_LM",
168
+ target_modules="all-linear",
169
+ )
170
+ return get_peft_model(model, config)
171
+
172
+
173
+ def create_model_archive() -> Path:
174
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
175
+ archive_path = OUTPUT_DIR / "model.tar.gz"
176
+ with tarfile.open(archive_path, "w:gz") as tar:
177
+ tar.add(MODEL_DIR, arcname="model")
178
+ return archive_path
179
+
180
+
181
+ def main() -> None:
182
+ started = time.time()
183
+ MODEL_DIR.mkdir(parents=True, exist_ok=True)
184
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
185
+
186
+ rows = load_rows()
187
+ model_source = resolve_model_source()
188
+ model, tokenizer = create_model_and_tokenizer(model_source)
189
+ model = maybe_apply_lora(model)
190
+
191
+ texts = [row_to_text(tokenizer, row) for row in rows]
192
+ dataset = TextDataset(texts, tokenizer, max_length=hp_int("max_seq_length", 2048))
193
+ collator = DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False)
194
+
195
+ with TemporaryDirectory() as tmp:
196
+ args = TrainingArguments(
197
+ output_dir=tmp,
198
+ num_train_epochs=hp_int("n_epochs", 3),
199
+ learning_rate=hp_float("learning_rate", 0.00001),
200
+ per_device_train_batch_size=hp_int("per_device_train_batch_size", 1),
201
+ gradient_accumulation_steps=hp_int("gradient_accumulation_steps", 8),
202
+ logging_steps=1,
203
+ save_strategy="no",
204
+ report_to=[],
205
+ remove_unused_columns=False,
206
+ bf16=torch.cuda.is_available() and torch.cuda.is_bf16_supported(),
207
+ fp16=torch.cuda.is_available() and not torch.cuda.is_bf16_supported(),
208
+ )
209
+ trainer = Trainer(
210
+ model=model,
211
+ args=args,
212
+ train_dataset=dataset,
213
+ data_collator=collator,
214
+ )
215
+ result = trainer.train()
216
+
217
+ model.save_pretrained(MODEL_DIR)
218
+ tokenizer.save_pretrained(MODEL_DIR)
219
+ archive_path = create_model_archive()
220
+ metrics = {
221
+ "training_rows": len(rows),
222
+ "model_source": model_source,
223
+ "train_runtime": round(time.time() - started, 3),
224
+ **{key: float(value) for key, value in result.metrics.items() if isinstance(value, (int, float))},
225
+ "model_archive": str(archive_path),
226
+ }
227
+ (MODEL_DIR / "training-metrics.json").write_text(json.dumps(metrics, indent=2))
228
+ (OUTPUT_DIR / "training-metrics.json").write_text(json.dumps(metrics, indent=2))
229
+ print(json.dumps(metrics, indent=2))
230
+
231
+
232
+ if __name__ == "__main__":
233
+ main()