@tuned-tensor/local 0.2.9 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +110 -201
  3. package/dist/active-model.d.ts +16 -0
  4. package/dist/active-model.js +88 -0
  5. package/dist/active-model.js.map +1 -0
  6. package/dist/artifacts.d.ts +5 -4
  7. package/dist/artifacts.js +57 -33
  8. package/dist/artifacts.js.map +1 -1
  9. package/dist/compare.d.ts +1 -8
  10. package/dist/compare.js +1 -23
  11. package/dist/compare.js.map +1 -1
  12. package/dist/contracts.d.ts +234 -365
  13. package/dist/contracts.js +95 -224
  14. package/dist/contracts.js.map +1 -1
  15. package/dist/dataset.d.ts +11 -4
  16. package/dist/dataset.js +67 -125
  17. package/dist/dataset.js.map +1 -1
  18. package/dist/doctor.d.ts +2 -3
  19. package/dist/doctor.js +23 -161
  20. package/dist/doctor.js.map +1 -1
  21. package/dist/evaluation.d.ts +11 -71
  22. package/dist/evaluation.js +212 -572
  23. package/dist/evaluation.js.map +1 -1
  24. package/dist/general-regression.d.ts +10 -0
  25. package/dist/general-regression.js +16 -0
  26. package/dist/general-regression.js.map +1 -0
  27. package/dist/huggingface-cache.d.ts +8 -7
  28. package/dist/huggingface-cache.js +16 -8
  29. package/dist/huggingface-cache.js.map +1 -1
  30. package/dist/index.d.ts +2 -10
  31. package/dist/index.js +358 -627
  32. package/dist/index.js.map +1 -1
  33. package/dist/local-project.d.ts +1 -11
  34. package/dist/local-project.js +33 -61
  35. package/dist/local-project.js.map +1 -1
  36. package/dist/model-registry.d.ts +3 -16
  37. package/dist/model-registry.js +76 -292
  38. package/dist/model-registry.js.map +1 -1
  39. package/dist/model-server.d.ts +7 -3
  40. package/dist/model-server.js +34 -25
  41. package/dist/model-server.js.map +1 -1
  42. package/dist/orchestrator.d.ts +11 -25
  43. package/dist/orchestrator.js +393 -563
  44. package/dist/orchestrator.js.map +1 -1
  45. package/dist/prefetch.d.ts +1 -5
  46. package/dist/prefetch.js +14 -19
  47. package/dist/prefetch.js.map +1 -1
  48. package/dist/process-runner.d.ts +10 -29
  49. package/dist/process-runner.js +63 -169
  50. package/dist/process-runner.js.map +1 -1
  51. package/dist/process-training.d.ts +0 -1
  52. package/dist/process-training.js +10 -87
  53. package/dist/process-training.js.map +1 -1
  54. package/dist/store.d.ts +1 -3
  55. package/dist/store.js +92 -290
  56. package/dist/store.js.map +1 -1
  57. package/docs/architecture.md +87 -152
  58. package/docs/spark.md +66 -97
  59. package/examples/dry-runner.json +14 -0
  60. package/examples/general-regression.jsonl +4 -0
  61. package/examples/local-runner.json +13 -6
  62. package/examples/smoke-spec.json +41 -0
  63. package/package.json +6 -6
  64. package/training/local-runner/pyproject.toml +0 -5
  65. package/training/local-runner/src/evaluate.py +89 -234
  66. package/training/local-runner/src/model_contract.py +47 -0
  67. package/training/local-runner/src/prefetch.py +18 -4
  68. package/training/local-runner/src/serve.py +60 -117
  69. package/training/local-runner/src/sft_data.py +97 -0
  70. package/training/local-runner/src/train.py +146 -346
  71. package/training/local-runner/uv.lock +0 -1197
  72. package/dist/labeling-sanitize.d.ts +0 -31
  73. package/dist/labeling-sanitize.js +0 -158
  74. package/dist/labeling-sanitize.js.map +0 -1
  75. package/dist/labeling.d.ts +0 -155
  76. package/dist/labeling.js +0 -496
  77. package/dist/labeling.js.map +0 -1
  78. package/dist/openrouter.d.ts +0 -29
  79. package/dist/openrouter.js +0 -66
  80. package/dist/openrouter.js.map +0 -1
  81. package/dist/server.d.ts +0 -9
  82. package/dist/server.js +0 -211
  83. package/dist/server.js.map +0 -1
  84. package/docs/local-workflow-remediation-2026-07-13.md +0 -130
  85. package/docs/local-workflow-ux-review-2026-07-13.md +0 -458
  86. package/examples/dpo-preferences.jsonl +0 -2
  87. package/examples/dpo-run-request.json +0 -34
  88. package/examples/smoke-run-request.json +0 -34
  89. package/training/local-runner/src/train_dpo.py +0 -286
package/dist/index.js CHANGED
@@ -1,39 +1,34 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync, realpathSync } from "node:fs";
3
3
  import { createHash } from "node:crypto";
4
- import { mkdir, open, readFile, stat } from "node:fs/promises";
5
- import { spawn } from "node:child_process";
4
+ import { readFile, stat } from "node:fs/promises";
6
5
  import { basename, dirname, join, resolve } from "node:path";
7
6
  import { cwd } from "node:process";
8
7
  import { fileURLToPath } from "node:url";
9
8
  import { compareRuns } from "./compare.js";
10
- import { assertArtifactManifest, claimRunArtifactDirectory, defaultArtifactPrefix, resolveRunArtifacts } from "./artifacts.js";
11
- import { fineTuneRunRequestSchema, localBehaviorSpecFileSchema, localRunnerConfigSchema, specSnapshotSchema } from "./contracts.js";
9
+ import { assertArtifactManifest } from "./artifacts.js";
10
+ import { fineTuneRunRequestSchema, localBehaviorSpecFileSchema, localRunnerConfigSchema } from "./contracts.js";
12
11
  import { buildSystemMessage } from "./dataset.js";
13
- import { assertEvaluationScoringReady } from "./evaluation.js";
14
- import { runLocalLabelingJob } from "./labeling.js";
15
- import { loadLocalRunnerConfig, fingerprintLocalBaseModel, runLocalFineTuneStage, } from "./orchestrator.js";
12
+ import { loadLocalRunnerConfig, fingerprintLocalBaseModel, runLocalFineTune, validateLocalFineTuneInput, } from "./orchestrator.js";
16
13
  import { runDoctor } from "./doctor.js";
17
- import { assertUsableModelArtifact, resolveTrainingModel } from "./model-registry.js";
18
- import { buildLocalModelServerLaunch, serveLocalModel } from "./model-server.js";
14
+ import { assertUsableModelArtifact } from "./model-registry.js";
15
+ import { buildLocalBaseModelServerLaunch, buildLocalModelServerLaunch, serveLocalModel, } from "./model-server.js";
19
16
  import { prefetchBaseModel } from "./prefetch.js";
20
- import { createLocalStore, isTerminalRunState } from "./store.js";
21
- import { serveLocalDashboard } from "./server.js";
22
- import { DEFAULT_LOCAL_SPEC_PATH, assertLocalRunInputReady, initLocalRunnerConfigFile, initLocalSpecFile, loadLocalRunInput, runRequestFromLocalSpec, } from "./local-project.js";
17
+ import { createLocalStore } from "./store.js";
18
+ import { DEFAULT_LOCAL_SPEC_PATH, assertLocalRunInputReady, initLocalRunnerConfigFile, initLocalSpecFile, loadLocalRunInput, } from "./local-project.js";
23
19
  import { sanitizeLogLine } from "./run-reporter.js";
20
+ import { activateModel, getActiveModel, rollbackActiveModel } from "./active-model.js";
24
21
  export * from "./compare.js";
25
22
  export * from "./contracts.js";
26
23
  export * from "./dataset.js";
27
- export * from "./labeling.js";
28
- export * from "./labeling-sanitize.js";
29
24
  export * from "./model-server.js";
30
25
  export * from "./orchestrator.js";
31
26
  export * from "./local-project.js";
32
- export * from "./openrouter.js";
33
27
  export * from "./prefetch.js";
34
28
  export * from "./run-reporter.js";
35
- export * from "./server.js";
36
29
  export * from "./store.js";
30
+ export * from "./general-regression.js";
31
+ export * from "./active-model.js";
37
32
  function packageVersion() {
38
33
  try {
39
34
  const packageJson = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
@@ -48,54 +43,10 @@ export function getLocalRunnerInfo() {
48
43
  return {
49
44
  name: "tuned-tensor-local",
50
45
  status: "local-runner-preview",
51
- description: "Local-first fine-tuning with baseline-vs-tuned evaluation for small open-weight models.",
46
+ description: "Local CUDA LoRA fine-tuning with held-out base-versus-tuned evaluation.",
52
47
  version: TT_LOCAL_VERSION,
53
48
  };
54
49
  }
55
- /**
56
- * Parses simple KEY=VALUE lines from .env content. Supports comments, blank
57
- * lines, an optional `export ` prefix, and single/double quoted values. Does
58
- * not support multiline values or variable expansion.
59
- */
60
- export function parseDotEnv(content) {
61
- const values = {};
62
- for (const rawLine of content.split(/\r?\n/)) {
63
- const line = rawLine.trim();
64
- if (!line || line.startsWith("#"))
65
- continue;
66
- const match = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
67
- if (!match)
68
- continue;
69
- let value = match[2].trim();
70
- if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
71
- value = value.slice(1, -1);
72
- }
73
- values[match[1]] = value;
74
- }
75
- return values;
76
- }
77
- /**
78
- * Loads .env from the working directory into process.env without overriding
79
- * variables that are already set, so `OPENROUTER_API_KEY` and friends work
80
- * out of the box in project directories. Returns the names that were loaded.
81
- */
82
- async function loadDotEnvFromCwd() {
83
- let content;
84
- try {
85
- content = await readFile(resolve(cwd(), ".env"), "utf8");
86
- }
87
- catch {
88
- return [];
89
- }
90
- const loaded = [];
91
- for (const [key, value] of Object.entries(parseDotEnv(content))) {
92
- if (process.env[key] === undefined) {
93
- process.env[key] = value;
94
- loaded.push(key);
95
- }
96
- }
97
- return loaded;
98
- }
99
50
  function readOption(argv, name) {
100
51
  const index = argv.indexOf(name);
101
52
  if (index !== -1)
@@ -112,18 +63,12 @@ function printHelp() {
112
63
  Commands:
113
64
  info Show package and runner information
114
65
  init [--name "My Local Model"] [--model Qwen/Qwen3.5-2B] [--output tunedtensor.json] [--profile spark] [--force]
115
- doctor [tunedtensor.json|request.json] [--config local-runner.json]
116
- validate [tunedtensor.json|request.json] [--config local-runner.json]
117
- run [tunedtensor.json|request.json] [--config local-runner.json] [--stage all|prepare|baseline|train|candidate|score|report]
118
- [--run-id uuid] [--parent-model local-model-id] [--parent-model-artifact path-or-file-uri]
119
- [--model-artifact path-or-file-uri] [--force] [--dry-run] [--detach] [--verbose] [--quiet]
120
- label <input.jsonl|input.csv> [--input-column col] [--spec tunedtensor.json] [--system-prompt "..."]
121
- [--model teacher-model] [--output labeled.jsonl] [--config local-runner.json] [--dry-run]
122
- serve [--config local-runner.json] [--host 127.0.0.1] [--port 8787]
123
- runs list|get|events|watch|report|compare|cancel|reconcile [args] [--config local-runner.json]
124
- models list|get|verify|prefetch|verify-base|serve [args] [--config local-runner.json]
125
- specs list|get|import [args] [--config local-runner.json]
126
- store rebuild-index [--config local-runner.json]
66
+ doctor [tunedtensor.json] [--config local-runner.json]
67
+ validate [tunedtensor.json] [--config local-runner.json]
68
+ run [tunedtensor.json] [--config local-runner.json] [--dry-run] [--verbose] [--quiet]
69
+ serve <model-id|active|base> [--config local-runner.json] [--host 127.0.0.1] [--port 8000]
70
+ runs list|get|events|report|compare [args] [--config local-runner.json]
71
+ models list|get|verify|prefetch|verify-base|active|activate|rollback|serve [args] [--config local-runner.json]
127
72
 
128
73
  Global options:
129
74
  -h, --help Show help
@@ -134,10 +79,23 @@ The run command writes local artifacts under config.artifactRoot, defaulting to
134
79
  ~/.tuned-tensor-local unless config.storeRoot or TT_LOCAL_HOME is set.`);
135
80
  }
136
81
  const CONFIG_OPTION = { name: "--config", value: "path", description: "Local runner config JSON path" };
137
- const USER_ID_OPTION = { name: "--user-id", value: "id", description: "Override the local user ID" };
138
- const RUN_NUMBER_OPTION = { name: "--run-number", value: "number", description: "Override the local run number" };
139
82
  const VERBOSE_OPTION = { name: "--verbose", description: "Stream subprocess output" };
140
83
  const QUIET_OPTION = { name: "--quiet", description: "Suppress progress output on stderr" };
84
+ const MODEL_SERVE_OPTIONS = [
85
+ CONFIG_OPTION,
86
+ { name: "--host", value: "host", description: "Bind host (localhost by default)" },
87
+ { name: "--port", value: "port", description: "Bind port" },
88
+ { name: "--device", value: "device", description: "cuda or cpu" },
89
+ { name: "--max-tokens", value: "count", description: "Default response token limit" },
90
+ { name: "--temperature", value: "number", description: "Default sampling temperature" },
91
+ { name: "--top-p", value: "number", description: "Default nucleus sampling threshold" },
92
+ { name: "--max-concurrent-requests", value: "count", description: "Concurrent generation limit" },
93
+ { name: "--spec", value: "path", description: "Behavior spec whose instructions are enforced" },
94
+ { name: "--no-spec-prompt", description: "Do not enforce the stored behavior-spec prompt" },
95
+ { name: "--allow-remote", description: "Allow a non-loopback bind" },
96
+ { name: "--api-key-env", value: "name", description: "Environment variable containing a bearer token" },
97
+ { name: "--print-command", description: "Validate and print the launch plan without starting" },
98
+ ];
141
99
  const COMMAND_DEFINITIONS = {
142
100
  info: {
143
101
  usage: "tt-local info",
@@ -153,86 +111,53 @@ const COMMAND_DEFINITIONS = {
153
111
  { name: "--model", value: "model", description: "Base model ID" },
154
112
  { name: "--output", value: "path", description: "Output spec path" },
155
113
  { name: "--profile", value: "profile", description: "Write a durable runner config (spark)" },
156
- { name: "--config-output", value: "path", description: "Profile config output path" },
114
+ { name: "--config", value: "path", description: "Runner config path (written with --profile)" },
157
115
  { name: "--force", description: "Overwrite an existing output file" },
158
116
  ],
159
117
  maxPositionals: 0,
160
118
  },
161
119
  doctor: {
162
- usage: "tt-local doctor [tunedtensor.json|request.json] [--config path]",
120
+ usage: "tt-local doctor [tunedtensor.json] [--config path]",
163
121
  description: "Check the host and optional run input before starting work.",
164
122
  options: [CONFIG_OPTION],
165
123
  maxPositionals: 1,
166
124
  },
167
125
  validate: {
168
- usage: "tt-local validate [tunedtensor.json|request.json] [options]",
169
- description: "Validate a behavior spec or run request without executing it.",
170
- options: [CONFIG_OPTION, USER_ID_OPTION, RUN_NUMBER_OPTION],
126
+ usage: "tt-local validate [tunedtensor.json] [options]",
127
+ description: "Validate a local behavior spec without executing it.",
128
+ options: [CONFIG_OPTION],
171
129
  maxPositionals: 1,
172
130
  },
173
131
  run: {
174
- usage: "tt-local run [tunedtensor.json|request.json] [options]",
132
+ usage: "tt-local run [tunedtensor.json] [options]",
175
133
  description: "Run the baseline, fine-tuning, tuned evaluation, and report workflow.",
176
134
  options: [
177
135
  CONFIG_OPTION,
178
- USER_ID_OPTION,
179
- RUN_NUMBER_OPTION,
180
- { name: "--run-id", value: "uuid", description: "Resume or override a run ID" },
181
- { name: "--stage", value: "stage", description: "Run through a specific workflow stage" },
182
- { name: "--model-artifact", value: "uri", description: "Use an existing model artifact" },
183
- { name: "--parent-model", value: "model-id", description: "Continue training a stored local model" },
184
- { name: "--parent-model-artifact", value: "uri", description: "Continue training a model artifact" },
185
- { name: "--force", description: "Recompute the selected stage" },
186
136
  { name: "--dry-run", description: "Write representative artifacts without GPU work" },
187
- { name: "--detach", description: "Run in the background and return the run ID immediately" },
188
137
  VERBOSE_OPTION,
189
138
  QUIET_OPTION,
190
139
  ],
191
140
  maxPositionals: 1,
192
141
  },
193
- label: {
194
- usage: "tt-local label <input.jsonl|input.csv> [options]",
195
- description: "Label local rows with a teacher model.",
196
- options: [
197
- CONFIG_OPTION,
198
- { name: "--input-column", value: "column", description: "CSV input column" },
199
- { name: "--spec", value: "path", description: "Behavior spec path" },
200
- { name: "--system-prompt", value: "text", description: "Explicit teacher system prompt" },
201
- { name: "--model", value: "model", description: "Teacher model ID" },
202
- { name: "--output", value: "path", description: "Output JSONL path" },
203
- { name: "--format", value: "jsonl|csv", description: "Force the source format" },
204
- { name: "--dry-run", description: "Parse and sanitize without teacher calls" },
205
- VERBOSE_OPTION,
206
- QUIET_OPTION,
207
- ],
142
+ serve: {
143
+ usage: "tt-local serve <model-id|active|base> [options]",
144
+ description: "Serve a verified adapter, the active model, or the protected base.",
145
+ options: MODEL_SERVE_OPTIONS,
208
146
  minPositionals: 1,
209
147
  maxPositionals: 1,
210
- missingPositionalsMessage: "label requires <input.jsonl|input.csv>",
211
- },
212
- serve: {
213
- usage: "tt-local serve [--config path] [--host host] [--port port]",
214
- description: "Serve the local runs dashboard.",
215
- options: [
216
- CONFIG_OPTION,
217
- { name: "--host", value: "host", description: "Dashboard bind host" },
218
- { name: "--port", value: "port", description: "Dashboard bind port" },
219
- ],
220
- maxPositionals: 0,
148
+ missingPositionalsMessage: "serve requires <model-id>",
221
149
  },
222
150
  };
223
151
  const COMMAND_GROUPS = {
224
152
  runs: {
225
- description: "Inspect and manage locally stored runs.",
153
+ description: "Inspect locally stored runs.",
226
154
  defaultSubcommand: "list",
227
155
  subcommands: {
228
156
  list: { usage: "tt-local runs list [--config path]", description: "List local runs.", options: [CONFIG_OPTION], maxPositionals: 0 },
229
157
  get: { usage: "tt-local runs get <run-id> [--config path]", description: "Get a local run.", options: [CONFIG_OPTION], minPositionals: 1, maxPositionals: 1, missingPositionalsMessage: "runs get requires <run-id>" },
230
158
  events: { usage: "tt-local runs events <run-id> [--config path]", description: "List run events.", options: [CONFIG_OPTION], minPositionals: 1, maxPositionals: 1, missingPositionalsMessage: "runs events requires <run-id>" },
231
- watch: { usage: "tt-local runs watch <run-id> [--config path]", description: "Watch a run until it finishes.", options: [CONFIG_OPTION], minPositionals: 1, maxPositionals: 1, missingPositionalsMessage: "runs watch requires <run-id>" },
232
159
  report: { usage: "tt-local runs report <run-id> [--config path]", description: "Show the baseline-vs-tuned report, including deltas and regressions.", options: [CONFIG_OPTION], minPositionals: 1, maxPositionals: 1, missingPositionalsMessage: "runs report requires <run-id>" },
233
160
  compare: { usage: "tt-local runs compare <run-id-a> <run-id-b> [--config path]", description: "Compare two run reports.", options: [CONFIG_OPTION], minPositionals: 2, maxPositionals: 2, missingPositionalsMessage: "runs compare requires <run-id-a> <run-id-b>" },
234
- cancel: { usage: "tt-local runs cancel <run-id> [--config path]", description: "Request cancellation of a run.", options: [CONFIG_OPTION], minPositionals: 1, maxPositionals: 1, missingPositionalsMessage: "runs cancel requires <run-id>" },
235
- reconcile: { usage: "tt-local runs reconcile [--config path]", description: "Rebuild local store indexes.", options: [CONFIG_OPTION], maxPositionals: 0 },
236
161
  },
237
162
  },
238
163
  models: {
@@ -250,63 +175,47 @@ const COMMAND_GROUPS = {
250
175
  missingPositionalsMessage: "models verify requires <model-id-or-artifact-path>",
251
176
  },
252
177
  prefetch: {
253
- usage: "tt-local models prefetch [tunedtensor.json|request.json] [options]",
178
+ usage: "tt-local models prefetch [tunedtensor.json] [options]",
254
179
  description: "Download the configured base model before a run.",
255
- options: [CONFIG_OPTION, USER_ID_OPTION, RUN_NUMBER_OPTION, VERBOSE_OPTION, QUIET_OPTION],
180
+ options: [CONFIG_OPTION, VERBOSE_OPTION, QUIET_OPTION],
256
181
  maxPositionals: 1,
257
182
  },
258
183
  "verify-base": {
259
- usage: "tt-local models verify-base [tunedtensor.json|request.json] [options]",
184
+ usage: "tt-local models verify-base [tunedtensor.json] [options]",
260
185
  description: "Verify that the configured base-model snapshot is complete and locally available.",
261
- options: [CONFIG_OPTION, USER_ID_OPTION, RUN_NUMBER_OPTION, VERBOSE_OPTION, QUIET_OPTION],
186
+ options: [CONFIG_OPTION, VERBOSE_OPTION, QUIET_OPTION],
262
187
  maxPositionals: 1,
263
188
  },
264
- serve: {
265
- usage: "tt-local models serve <model-id> [options]",
266
- description: "Serve a verified adapter through an OpenAI-compatible local API.",
267
- options: [
268
- CONFIG_OPTION,
269
- { name: "--host", value: "host", description: "Bind host (localhost by default)" },
270
- { name: "--port", value: "port", description: "Bind port" },
271
- { name: "--device", value: "device", description: "auto, cpu, cuda, or mps" },
272
- { name: "--max-tokens", value: "count", description: "Default response token limit" },
273
- { name: "--temperature", value: "number", description: "Default sampling temperature" },
274
- { name: "--top-p", value: "number", description: "Default nucleus sampling threshold" },
275
- { name: "--max-concurrent-requests", value: "count", description: "Concurrent generation limit" },
276
- { name: "--spec", value: "path", description: "Behavior spec whose instructions are enforced" },
277
- { name: "--no-spec-prompt", description: "Do not enforce the stored behavior-spec prompt" },
278
- { name: "--allow-remote", description: "Allow a non-loopback bind" },
279
- { name: "--api-key-env", value: "name", description: "Environment variable containing a bearer token" },
280
- { name: "--print-command", description: "Validate and print the launch plan without starting" },
281
- ],
189
+ active: {
190
+ usage: "tt-local models active [--config path]",
191
+ description: "Show the active adapter, or the protected base when none is active.",
192
+ options: [CONFIG_OPTION],
193
+ maxPositionals: 0,
194
+ },
195
+ activate: {
196
+ usage: "tt-local models activate <model-id> [--config path]",
197
+ description: "Activate a verified model whose general regression gate passed.",
198
+ options: [CONFIG_OPTION],
282
199
  minPositionals: 1,
283
200
  maxPositionals: 1,
284
- missingPositionalsMessage: "models serve requires <model-id>",
201
+ missingPositionalsMessage: "models activate requires <model-id>",
285
202
  },
286
- },
287
- },
288
- specs: {
289
- description: "Inspect and import local behavior specs.",
290
- defaultSubcommand: "list",
291
- subcommands: {
292
- list: { usage: "tt-local specs list [--config path]", description: "List local behavior specs.", options: [CONFIG_OPTION], maxPositionals: 0 },
293
- get: { usage: "tt-local specs get <spec-id> [--config path]", description: "Get a local behavior spec.", options: [CONFIG_OPTION], minPositionals: 1, maxPositionals: 1, missingPositionalsMessage: "specs get requires <spec-id>" },
294
- import: {
295
- usage: "tt-local specs import <spec-or-request.json> [options]",
296
- description: "Import a behavior spec into the local store.",
297
- options: [CONFIG_OPTION, USER_ID_OPTION, RUN_NUMBER_OPTION, { name: "--id", value: "id", description: "ID for a raw spec snapshot" }],
203
+ rollback: {
204
+ usage: "tt-local models rollback [--config path]",
205
+ description: "Restore the previously active adapter or protected base.",
206
+ options: [CONFIG_OPTION],
207
+ maxPositionals: 0,
208
+ },
209
+ serve: {
210
+ usage: "tt-local models serve <model-id|active|base> [options]",
211
+ description: "Alias for `tt-local serve`.",
212
+ options: MODEL_SERVE_OPTIONS,
298
213
  minPositionals: 1,
299
214
  maxPositionals: 1,
300
- missingPositionalsMessage: "specs import requires <spec-or-request.json>",
215
+ missingPositionalsMessage: "models serve requires <model-id>",
301
216
  },
302
217
  },
303
218
  },
304
- store: {
305
- description: "Maintain the local file-backed store.",
306
- subcommands: {
307
- "rebuild-index": { usage: "tt-local store rebuild-index [--config path]", description: "Rebuild store indexes from durable records.", options: [CONFIG_OPTION], maxPositionals: 0 },
308
- },
309
- },
310
219
  };
311
220
  function hasHelpFlag(argv) {
312
221
  return argv.includes("--help") || argv.includes("-h");
@@ -439,22 +348,32 @@ function readNumberOption(argv, name) {
439
348
  const value = readOption(argv, name);
440
349
  return value ? Number(value) : undefined;
441
350
  }
442
- function readRunStage(argv) {
443
- const value = readOption(argv, "--stage") ?? "all";
444
- if (value === "prepare"
445
- || value === "baseline"
446
- || value === "train"
447
- || value === "candidate"
448
- || value === "score"
449
- || value === "report"
450
- || value === "all") {
451
- return value;
452
- }
453
- throw new Error(`--stage must be one of prepare, baseline, train, candidate, score, report, all; got: ${value}`);
351
+ async function selectedConfigPath(argv, adjacentTo) {
352
+ const explicitPath = readOption(argv, "--config");
353
+ if (explicitPath)
354
+ return resolve(explicitPath);
355
+ const candidate = join(adjacentTo ? dirname(resolve(adjacentTo)) : cwd(), "local-runner.json");
356
+ const metadata = await stat(candidate).catch(() => null);
357
+ return metadata?.isFile() ? candidate : undefined;
454
358
  }
455
- async function configFromArgv(argv) {
456
- const configPath = readOption(argv, "--config");
457
- return loadLocalRunnerConfig(configPath ? resolve(configPath) : undefined);
359
+ async function configSelectionFromArgv(argv, adjacentTo) {
360
+ const path = await selectedConfigPath(argv, adjacentTo);
361
+ return {
362
+ config: await loadLocalRunnerConfig(path),
363
+ ...(path ? { path } : {}),
364
+ };
365
+ }
366
+ async function configFromArgv(argv, adjacentTo) {
367
+ return (await configSelectionFromArgv(argv, adjacentTo)).config;
368
+ }
369
+ async function loadCliBehaviorSpec(inputPath, runId) {
370
+ const input = await loadLocalRunInput(inputPath, {
371
+ ...(runId ? { runId } : {}),
372
+ });
373
+ if (input.kind !== "spec") {
374
+ throw new Error(`TT Local CLI expects a tunedtensor.json behavior spec, not a full run request: ${input.path}`);
375
+ }
376
+ return input;
458
377
  }
459
378
  function printJson(value) {
460
379
  console.log(JSON.stringify(value, null, 2));
@@ -503,162 +422,7 @@ function createConsoleReporter(options) {
503
422
  },
504
423
  };
505
424
  }
506
- function assertSupportedValidateShape(request, config) {
507
- if (request.training_method !== "dpo" || config.training.backend === "command")
508
- return;
509
- const model = resolveTrainingModel(request.spec_snapshot.base_model);
510
- if (model.loader === "image_text_to_text") {
511
- throw new Error("Bundled DPO training supports text causal-LM models only in v1. Use a causal_lm base model or training.backend=command for a custom DPO trainer.");
512
- }
513
- }
514
- async function parentModelSelectionFromArgv(argv, config) {
515
- const parentModel = readOption(argv, "--parent-model");
516
- const parentModelArtifact = readOption(argv, "--parent-model-artifact");
517
- if (parentModel && parentModelArtifact) {
518
- throw new Error("Use only one of --parent-model or --parent-model-artifact.");
519
- }
520
- if (parentModelArtifact)
521
- return { artifactUri: parentModelArtifact };
522
- if (!parentModel)
523
- return undefined;
524
- const store = createLocalStore(config.storeRoot);
525
- const record = await store.getModel(parentModel);
526
- const verified = await verifyStoredModel(record, { requireServable: true });
527
- const contract = verified.contract;
528
- const baseModelRevision = typeof contract.base_model_revision === "string"
529
- ? contract.base_model_revision
530
- : undefined;
531
- const baseModelArtifactUri = typeof contract.base_model_artifact_uri === "string"
532
- ? contract.base_model_artifact_uri
533
- : undefined;
534
- const baseModelFingerprint = typeof contract.base_model_fingerprint === "string"
535
- ? contract.base_model_fingerprint
536
- : undefined;
537
- if (baseModelArtifactUri && baseModelFingerprint) {
538
- if (!config.paths.baseModel) {
539
- throw new Error(`Parent model ${record.id} was trained from a local base snapshot. Configure paths.baseModel to the same `
540
- + "snapshot before continuing it.");
541
- }
542
- const configuredFingerprint = await fingerprintLocalBaseModel(config.paths.baseModel);
543
- if (configuredFingerprint !== baseModelFingerprint) {
544
- throw new Error(`Configured paths.baseModel content does not match parent model ${record.id}.`);
545
- }
546
- }
547
- else if (!baseModelRevision) {
548
- throw new Error(`Parent model ${record.id} does not record an immutable base-model revision or local snapshot fingerprint. `
549
- + "Use --parent-model-artifact only if you intentionally accept responsibility for that unpinned artifact.");
550
- }
551
- if (baseModelRevision && config.paths.baseModel) {
552
- const snapshotRevision = resolve(config.paths.baseModel).match(/[\\/]snapshots[\\/]([^\\/]+)(?:[\\/]|$)/)?.[1];
553
- if (!baseModelFingerprint && snapshotRevision !== baseModelRevision) {
554
- throw new Error(`Parent model ${record.id} requires base revision ${baseModelRevision}; configured paths.baseModel does not `
555
- + "identify that snapshot revision.");
556
- }
557
- }
558
- return {
559
- artifactUri: record.artifact_uri,
560
- modelId: record.id,
561
- baseModel: record.base_model,
562
- baseModelRevision,
563
- baseModelArtifactUri,
564
- baseModelFingerprint,
565
- };
566
- }
567
- function withParentModelArtifact(request, parentModel) {
568
- if (!parentModel)
569
- return request;
570
- if (parentModel.baseModel && parentModel.baseModel !== request.spec_snapshot.base_model) {
571
- throw new Error(`Parent model ${parentModel.modelId ?? ""} uses base model ${parentModel.baseModel}, `
572
- + `but this run uses ${request.spec_snapshot.base_model}. Continue from a model with the same base model.`);
573
- }
574
- const requestedRevision = request.hyperparameters.base_model_revision;
575
- if (parentModel.baseModelRevision && requestedRevision && parentModel.baseModelRevision !== requestedRevision) {
576
- throw new Error(`Parent model ${parentModel.modelId ?? ""} uses base revision ${parentModel.baseModelRevision}, `
577
- + `but this run requests ${requestedRevision}.`);
578
- }
579
- return fineTuneRunRequestSchema.parse({
580
- ...request,
581
- hyperparameters: {
582
- ...request.hyperparameters,
583
- ...(parentModel.baseModelRevision ? { base_model_revision: parentModel.baseModelRevision } : {}),
584
- parent_model_artifact: parentModel.artifactUri,
585
- },
586
- });
587
- }
588
- async function sleep(ms) {
589
- await new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
590
- }
591
- function detachedRunArgv(argv, runId) {
592
- const tokens = argv.slice(3);
593
- const output = [];
594
- for (let index = 0; index < tokens.length; index += 1) {
595
- const token = tokens[index];
596
- if (token === "--detach")
597
- continue;
598
- if (token === "--run-id") {
599
- index += 1;
600
- continue;
601
- }
602
- if (token.startsWith("--run-id="))
603
- continue;
604
- output.push(token);
605
- }
606
- return ["run", ...output, "--run-id", runId];
607
- }
608
- async function launchDetachedRun(args) {
609
- if (!process.argv[1])
610
- throw new Error("Cannot determine the tt-local CLI entrypoint for --detach.");
611
- const prefix = args.request.artifacts?.prefix ?? defaultArtifactPrefix({
612
- userId: args.request.user_id,
613
- behaviorSpecId: args.request.behavior_spec_id,
614
- runId: args.request.run_id,
615
- });
616
- const artifacts = resolveRunArtifacts({ artifactRoot: args.config.artifactRoot, prefix });
617
- await claimRunArtifactDirectory({
618
- artifacts,
619
- runId: args.request.run_id,
620
- userId: args.request.user_id,
621
- behaviorSpecId: args.request.behavior_spec_id,
622
- });
623
- const store = createLocalStore(args.config.storeRoot);
624
- const existing = await store.getRun(args.request.run_id).catch(() => null);
625
- if (existing) {
626
- await store.syncRunRequest(args.request);
627
- }
628
- else {
629
- await store.startRun({ request: args.request, artifactDir: artifacts.runDir });
630
- }
631
- const logPath = resolve(artifacts.runDir, "detached.log");
632
- await mkdir(dirname(logPath), { recursive: true });
633
- const log = await open(logPath, "a");
634
- try {
635
- const child = spawn(process.execPath, [
636
- ...process.execArgv,
637
- process.argv[1],
638
- ...detachedRunArgv(args.argv, args.request.run_id),
639
- ], {
640
- detached: true,
641
- stdio: ["ignore", log.fd, log.fd],
642
- env: { ...process.env, TT_LOCAL_DETACHED: "1" },
643
- });
644
- await new Promise((resolveSpawn, reject) => {
645
- child.once("spawn", resolveSpawn);
646
- child.once("error", reject);
647
- });
648
- if (!child.pid)
649
- throw new Error("Detached tt-local process did not report a PID.");
650
- child.unref();
651
- return { pid: child.pid, logPath, artifactDir: artifacts.runDir };
652
- }
653
- catch (error) {
654
- await store.failRun(args.request.run_id, `Failed to launch detached workflow: ${error instanceof Error ? error.message : String(error)}`).catch(() => undefined);
655
- throw error;
656
- }
657
- finally {
658
- await log.close();
659
- }
660
- }
661
- async function verifyStoredModel(model, options = {}) {
425
+ async function verifyStoredModel(model) {
662
426
  const manifestPath = join(model.artifact_dir, "artifact-manifest.json");
663
427
  const integrity = await assertArtifactManifest(manifestPath, {
664
428
  requiredPaths: ["stage-metadata.json", "training-report.json"],
@@ -669,11 +433,7 @@ async function verifyStoredModel(model, options = {}) {
669
433
  if (!manifest.model) {
670
434
  throw new Error(`Artifact manifest does not contain a model contract: ${manifestPath}`);
671
435
  }
672
- const customNonServable = manifest.model.framework !== "transformers-peft"
673
- && manifest.model.servable === false;
674
- const artifact = await assertUsableModelArtifact(model.artifact_uri, {
675
- allowUnrecognizedPayload: customNonServable && !options.requireServable,
676
- });
436
+ const artifact = await assertUsableModelArtifact(model.artifact_uri);
677
437
  if (typeof manifest.model.base_model_artifact_uri === "string"
678
438
  && typeof manifest.model.base_model_fingerprint === "string") {
679
439
  const actualBaseFingerprint = await fingerprintLocalBaseModel(manifest.model.base_model_artifact_uri);
@@ -688,9 +448,6 @@ async function verifyStoredModel(model, options = {}) {
688
448
  if (manifest.model.base_model !== model.base_model) {
689
449
  throw new Error("Stored model base model does not match its artifact manifest.");
690
450
  }
691
- if (options.requireServable && manifest.model.servable !== true) {
692
- throw new Error(`Model ${model.id} is valid but its artifact contract does not mark it as locally servable.`);
693
- }
694
451
  return {
695
452
  manifest_path: manifestPath,
696
453
  integrity,
@@ -698,6 +455,17 @@ async function verifyStoredModel(model, options = {}) {
698
455
  contract: manifest.model,
699
456
  };
700
457
  }
458
+ async function verifyActivationEvidence(model) {
459
+ await assertArtifactManifest(join(model.artifact_dir, "artifact-manifest.json"), {
460
+ requiredPaths: [
461
+ "run-report.json",
462
+ "general-baseline-eval.json",
463
+ "general-candidate-eval.json",
464
+ ],
465
+ scopeToRequired: true,
466
+ verifyModel: true,
467
+ });
468
+ }
701
469
  async function verifyModelArtifactPath(input) {
702
470
  const inputPath = resolve(input);
703
471
  let manifestPath;
@@ -743,11 +511,7 @@ async function verifyModelArtifactPath(input) {
743
511
  const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
744
512
  if (!manifest.model)
745
513
  throw new Error(`Artifact manifest does not contain a model contract: ${manifestPath}`);
746
- const customNonServable = manifest.model.framework !== "transformers-peft"
747
- && manifest.model.servable === false;
748
- const artifact = await assertUsableModelArtifact(artifactUri, {
749
- allowUnrecognizedPayload: customNonServable,
750
- });
514
+ const artifact = await assertUsableModelArtifact(artifactUri);
751
515
  if (typeof manifest.model.base_model_artifact_uri === "string"
752
516
  && typeof manifest.model.base_model_fingerprint === "string") {
753
517
  const actualBaseFingerprint = await fingerprintLocalBaseModel(manifest.model.base_model_artifact_uri);
@@ -772,12 +536,10 @@ async function modelSystemPrompt(args) {
772
536
  if (specPath) {
773
537
  const input = JSON.parse(await readFile(resolve(specPath), "utf8"));
774
538
  const local = localBehaviorSpecFileSchema.safeParse(input);
775
- const request = fineTuneRunRequestSchema.safeParse(input);
776
- const snapshot = specSnapshotSchema.safeParse(input);
777
- const parsedSpec = local.success ? local.data : request.success ? request.data.spec_snapshot : snapshot.success ? snapshot.data : null;
778
- if (!parsedSpec)
779
- throw new Error(`--spec must contain a TT Local behavior spec or run request: ${resolve(specPath)}`);
780
- spec = parsedSpec;
539
+ if (!local.success) {
540
+ throw new Error(`--spec must contain a tunedtensor.json behavior spec: ${resolve(specPath)}`);
541
+ }
542
+ spec = local.data;
781
543
  }
782
544
  else {
783
545
  const runRequestPath = join(args.store.paths.runsDir, args.model.run_id, "request.json");
@@ -806,10 +568,140 @@ async function modelSystemPrompt(args) {
806
568
  }
807
569
  function modelServeDevice(argv) {
808
570
  const value = readOption(argv, "--device");
809
- if (value === undefined || value === "auto" || value === "cpu" || value === "cuda" || value === "mps") {
571
+ if (value === undefined || value === "cpu" || value === "cuda") {
810
572
  return value;
811
573
  }
812
- throw new Error(`--device must be one of auto, cpu, cuda, mps; got: ${value}`);
574
+ throw new Error(`--device must be cuda or cpu; got: ${value}`);
575
+ }
576
+ async function serveStoredModelFromCli(args) {
577
+ const store = createLocalStore(args.config.storeRoot);
578
+ const model = await store.getModel(args.modelId);
579
+ const verified = await verifyStoredModel(model);
580
+ const launch = buildLocalModelServerLaunch({
581
+ model,
582
+ config: args.config,
583
+ options: {
584
+ host: readOption(args.argv, "--host"),
585
+ port: readNumberOption(args.argv, "--port"),
586
+ device: modelServeDevice(args.argv),
587
+ maxTokens: readNumberOption(args.argv, "--max-tokens"),
588
+ temperature: readNumberOption(args.argv, "--temperature"),
589
+ topP: readNumberOption(args.argv, "--top-p"),
590
+ maxConcurrentRequests: readNumberOption(args.argv, "--max-concurrent-requests"),
591
+ systemPrompt: await modelSystemPrompt({ argv: args.argv, model, store }),
592
+ allowRemote: hasFlag(args.argv, "--allow-remote"),
593
+ apiKeyEnv: readOption(args.argv, "--api-key-env"),
594
+ baseModelRevision: (() => {
595
+ const contract = verified.contract;
596
+ return typeof contract.base_model_revision === "string" ? contract.base_model_revision : undefined;
597
+ })(),
598
+ baseModelArtifactUri: (() => {
599
+ const contract = verified.contract;
600
+ return typeof contract.base_model_artifact_uri === "string" ? contract.base_model_artifact_uri : undefined;
601
+ })(),
602
+ },
603
+ });
604
+ if (hasFlag(args.argv, "--print-command")) {
605
+ printJson({
606
+ ok: true,
607
+ model_id: model.id,
608
+ url: launch.url,
609
+ command: launch.displayCommand,
610
+ artifact_path: launch.artifactPath,
611
+ manifest_path: verified.manifest_path,
612
+ integrity: verified.integrity,
613
+ });
614
+ return;
615
+ }
616
+ process.stderr.write(`[tt-local] verified ${verified.integrity.checked} artifact file(s)\n`);
617
+ process.stderr.write(`[tt-local] model API: ${launch.url}\n`);
618
+ await serveLocalModel(launch);
619
+ }
620
+ function serveOptionsFromArgv(argv) {
621
+ return {
622
+ host: readOption(argv, "--host"),
623
+ port: readNumberOption(argv, "--port"),
624
+ device: modelServeDevice(argv),
625
+ maxTokens: readNumberOption(argv, "--max-tokens"),
626
+ temperature: readNumberOption(argv, "--temperature"),
627
+ topP: readNumberOption(argv, "--top-p"),
628
+ maxConcurrentRequests: readNumberOption(argv, "--max-concurrent-requests"),
629
+ allowRemote: hasFlag(argv, "--allow-remote"),
630
+ apiKeyEnv: readOption(argv, "--api-key-env"),
631
+ };
632
+ }
633
+ async function serveBaseModelFromCli(args) {
634
+ const explicitSpecPath = readOption(args.argv, "--spec");
635
+ if (explicitSpecPath && hasFlag(args.argv, "--no-spec-prompt")) {
636
+ throw new Error("Use only one of --spec or --no-spec-prompt.");
637
+ }
638
+ const defaultSpecPath = resolve(DEFAULT_LOCAL_SPEC_PATH);
639
+ const specPath = explicitSpecPath
640
+ ? resolve(explicitSpecPath)
641
+ : (await stat(defaultSpecPath).catch(() => null))?.isFile()
642
+ ? defaultSpecPath
643
+ : undefined;
644
+ const spec = specPath
645
+ ? localBehaviorSpecFileSchema.parse(JSON.parse(await readFile(specPath, "utf8")))
646
+ : undefined;
647
+ let baseModel = spec?.base_model;
648
+ if (!baseModel) {
649
+ const store = createLocalStore(args.config.storeRoot);
650
+ const active = await getActiveModel(store);
651
+ const referenceModel = active.model
652
+ ?? (active.pointer?.previous_model_id
653
+ ? await store.getModel(active.pointer.previous_model_id)
654
+ : null);
655
+ baseModel = referenceModel?.base_model;
656
+ }
657
+ if (!baseModel) {
658
+ throw new Error("Cannot identify the protected base model. Pass --spec <tunedtensor.json> "
659
+ + "or activate a model first.");
660
+ }
661
+ const launch = buildLocalBaseModelServerLaunch({
662
+ baseModel,
663
+ config: args.config,
664
+ options: {
665
+ ...serveOptionsFromArgv(args.argv),
666
+ systemPrompt: explicitSpecPath && !hasFlag(args.argv, "--no-spec-prompt")
667
+ ? buildSystemMessage(spec)
668
+ : undefined,
669
+ baseModelRevision: spec?.hyperparameters?.base_model_revision,
670
+ },
671
+ });
672
+ if (hasFlag(args.argv, "--print-command")) {
673
+ printJson({
674
+ ok: true,
675
+ model_id: null,
676
+ base_model: baseModel,
677
+ url: launch.url,
678
+ command: launch.displayCommand,
679
+ });
680
+ return;
681
+ }
682
+ process.stderr.write(`[tt-local] protected base model API: ${launch.url}\n`);
683
+ await serveLocalModel(launch);
684
+ }
685
+ async function serveModelTargetFromCli(args) {
686
+ if (args.target === "base") {
687
+ return serveBaseModelFromCli(args);
688
+ }
689
+ if (args.target === "active") {
690
+ const store = createLocalStore(args.config.storeRoot);
691
+ const active = await getActiveModel(store);
692
+ if (!active.model)
693
+ return serveBaseModelFromCli(args);
694
+ return serveStoredModelFromCli({
695
+ argv: args.argv,
696
+ modelId: active.model.id,
697
+ config: args.config,
698
+ });
699
+ }
700
+ return serveStoredModelFromCli({
701
+ argv: args.argv,
702
+ modelId: args.target,
703
+ config: args.config,
704
+ });
813
705
  }
814
706
  async function main(argv) {
815
707
  const cli = parseCli(argv);
@@ -830,10 +722,6 @@ async function main(argv) {
830
722
  printCommandHelp(cli.definition);
831
723
  return;
832
724
  }
833
- const loadedEnv = await loadDotEnvFromCwd();
834
- if (loadedEnv.length > 0 && !hasFlag(argv, "--quiet")) {
835
- process.stderr.write(`[tt-local] loaded ${loadedEnv.join(", ")} from .env\n`);
836
- }
837
725
  if (command === "info") {
838
726
  const info = getLocalRunnerInfo();
839
727
  console.log(`${info.name}: ${info.description}`);
@@ -842,13 +730,21 @@ async function main(argv) {
842
730
  return;
843
731
  }
844
732
  if (command === "doctor") {
845
- const config = await configFromArgv(argv);
846
- const request = cli.positionals[0]
847
- ? (await loadLocalRunInput(resolve(cli.positionals[0]))).request
733
+ const inputPath = resolve(cli.positionals[0] ?? DEFAULT_LOCAL_SPEC_PATH);
734
+ const configSelection = await configSelectionFromArgv(argv, inputPath);
735
+ const hasDefaultSpec = cli.positionals[0]
736
+ ? true
737
+ : Boolean((await stat(inputPath).catch(() => null))?.isFile());
738
+ const request = hasDefaultSpec
739
+ ? (await loadCliBehaviorSpec(inputPath)).request
848
740
  : undefined;
849
- const checks = await runDoctor(config, request);
741
+ const checks = await runDoctor(configSelection.config, request);
850
742
  const ok = checks.every((check) => check.ok);
851
- printJson({ ok, checks });
743
+ printJson({
744
+ ok,
745
+ config_path: configSelection.path ?? null,
746
+ checks,
747
+ });
852
748
  if (!ok)
853
749
  process.exitCode = 1;
854
750
  return;
@@ -866,15 +762,18 @@ async function main(argv) {
866
762
  force: hasFlag(argv, "--force"),
867
763
  });
868
764
  const configPath = profile
869
- ? resolve(readOption(argv, "--config-output") ?? resolve(dirname(outputPath), "local-runner.json"))
870
- : undefined;
871
- if (profile && configPath) {
765
+ ? resolve(readOption(argv, "--config") ?? resolve(dirname(outputPath), "local-runner.json"))
766
+ : await selectedConfigPath(argv, outputPath);
767
+ if (profile) {
872
768
  await initLocalRunnerConfigFile({
873
769
  outputPath: configPath,
874
770
  profile,
875
771
  force: hasFlag(argv, "--force"),
876
772
  });
877
773
  }
774
+ else if (configPath) {
775
+ await loadLocalRunnerConfig(configPath);
776
+ }
878
777
  printJson({
879
778
  ok: true,
880
779
  path: outputPath,
@@ -887,174 +786,95 @@ async function main(argv) {
887
786
  }
888
787
  if (command === "validate") {
889
788
  const inputPath = resolve(cli.positionals[0] ?? DEFAULT_LOCAL_SPEC_PATH);
890
- const input = await loadLocalRunInput(inputPath, {
891
- userId: readOption(argv, "--user-id"),
892
- runNumber: readNumberOption(argv, "--run-number"),
789
+ const input = await loadCliBehaviorSpec(inputPath);
790
+ assertLocalRunInputReady(input.request);
791
+ const configSelection = await configSelectionFromArgv(argv, inputPath);
792
+ const validated = await validateLocalFineTuneInput({
793
+ request: input.request,
794
+ config: configSelection.config,
893
795
  });
894
- const request = input.request;
895
- const config = await configFromArgv(argv);
896
- assertLocalRunInputReady(request);
897
- assertSupportedValidateShape(request, config);
898
- assertEvaluationScoringReady(config);
796
+ const request = validated.request;
797
+ const config = validated.config;
899
798
  printJson({
900
799
  ok: true,
901
- input_kind: input.kind,
902
800
  input_path: input.path,
903
- run_id: input.kind === "request" ? request.run_id : null,
801
+ config_path: configSelection.path ?? null,
904
802
  behavior_spec_id: request.behavior_spec_id,
905
- training_method: request.training_method,
906
803
  base_model: request.spec_snapshot.base_model,
907
804
  dataset_format: request.dataset_prebuilt?.format ?? null,
908
805
  artifact_root: config.artifactRoot,
909
806
  store_root: config.storeRoot,
910
807
  dry_run: config.dryRun,
911
- ...(input.warnings.length > 0 ? { warnings: input.warnings } : {}),
912
808
  });
913
809
  return;
914
810
  }
915
811
  if (command === "run") {
916
812
  const inputPath = resolve(cli.positionals[0] ?? DEFAULT_LOCAL_SPEC_PATH);
917
- const configInput = await configFromArgv(argv);
813
+ const configSelection = await configSelectionFromArgv(argv, inputPath);
814
+ const configInput = configSelection.config;
918
815
  const config = localRunnerConfigSchema.parse({
919
816
  ...configInput,
920
817
  dryRun: hasFlag(argv, "--dry-run") ? true : configInput.dryRun,
921
818
  });
922
- const input = await loadLocalRunInput(inputPath, {
923
- runId: readOption(argv, "--run-id"),
924
- userId: readOption(argv, "--user-id"),
925
- runNumber: readNumberOption(argv, "--run-number"),
819
+ const input = await loadCliBehaviorSpec(inputPath);
820
+ assertLocalRunInputReady(input.request);
821
+ const validated = await validateLocalFineTuneInput({
822
+ request: input.request,
823
+ config,
824
+ });
825
+ let request = validated.request;
826
+ const reporter = createConsoleReporter({
827
+ verbose: hasFlag(argv, "--verbose"),
828
+ quiet: hasFlag(argv, "--quiet"),
926
829
  });
927
- if (!hasFlag(argv, "--quiet")) {
928
- for (const warning of input.warnings) {
929
- process.stderr.write(`[tt-local] warning: ${warning}\n`);
830
+ if (!config.dryRun
831
+ && !config.paths.baseModel
832
+ && !request.hyperparameters.base_model_revision) {
833
+ const prefetch = await prefetchBaseModel({
834
+ request,
835
+ config,
836
+ reporter,
837
+ });
838
+ if (!prefetch.snapshot_revision) {
839
+ throw new Error("Base-model prefetch did not return an immutable snapshot revision.");
930
840
  }
931
- }
932
- const runId = readOption(argv, "--run-id");
933
- const baseRequest = runId && input.kind === "request"
934
- ? fineTuneRunRequestSchema.parse({ ...input.request, run_id: runId })
935
- : input.request;
936
- const request = withParentModelArtifact(baseRequest, await parentModelSelectionFromArgv(argv, config));
937
- assertLocalRunInputReady(request);
938
- assertSupportedValidateShape(request, config);
939
- assertEvaluationScoringReady(config);
940
- if (hasFlag(argv, "--detach")) {
941
- const detached = await launchDetachedRun({ argv, request, config });
942
- const configPath = readOption(argv, "--config");
943
- const configSuffix = configPath ? ` --config ${JSON.stringify(resolve(configPath))}` : "";
944
- printJson({
945
- status: "queued",
946
- detached: true,
947
- run_id: request.run_id,
948
- behavior_spec_id: request.behavior_spec_id,
949
- pid: detached.pid,
950
- log_path: detached.logPath,
951
- artifact_dir: detached.artifactDir,
952
- next: {
953
- watch: `tt-local runs watch ${request.run_id}${configSuffix}`,
954
- cancel: `tt-local runs cancel ${request.run_id}${configSuffix}`,
841
+ request = fineTuneRunRequestSchema.parse({
842
+ ...request,
843
+ hyperparameters: {
844
+ ...request.hyperparameters,
845
+ base_model_revision: prefetch.snapshot_revision,
955
846
  },
956
847
  });
957
- return;
958
848
  }
959
- const stage = readRunStage(argv);
960
- const result = await runLocalFineTuneStage({
849
+ const result = await runLocalFineTune({
961
850
  request,
962
851
  config,
963
- stage,
964
- force: hasFlag(argv, "--force"),
965
- modelArtifact: readOption(argv, "--model-artifact"),
966
- reporter: createConsoleReporter({
967
- verbose: hasFlag(argv, "--verbose"),
968
- quiet: hasFlag(argv, "--quiet"),
969
- }),
852
+ reporter,
970
853
  });
971
- if (result.report) {
972
- printJson({
973
- status: result.report.status,
974
- stage: result.stage,
975
- input_kind: input.kind,
976
- run_id: result.report.run_id,
977
- behavior_spec_id: result.report.behavior_spec_id,
978
- report_path: result.reportPath,
979
- artifact_dir: result.artifactDir,
854
+ printJson({
855
+ status: result.report.status,
856
+ run_id: result.report.run_id,
857
+ behavior_spec_id: result.report.behavior_spec_id,
858
+ report_path: result.reportPath,
859
+ artifact_dir: result.artifactDir,
860
+ ...(!config.dryRun ? {
980
861
  model_id: `local-${result.report.run_id}`,
981
862
  fine_tuned_model_id: result.report.fine_tuned_model_id,
982
- parent_model_artifact: result.report.run_metadata.parent_model_artifact ?? null,
983
- training_log: result.report.training.log_uri,
984
- baseline_eval: result.report.artifact_uris.baseline_eval,
985
- candidate_eval: result.report.artifact_uris.candidate_eval,
986
- comparison: result.report.comparison,
987
- });
988
- }
989
- else {
990
- printJson({
991
- status: "stage_completed",
992
- stage: result.stage,
993
- input_kind: input.kind,
994
- run_id: result.request.run_id,
995
- behavior_spec_id: result.request.behavior_spec_id,
996
- artifact_dir: result.artifactDir,
997
- parent_model_artifact: result.request.hyperparameters.parent_model_artifact ?? null,
998
- artifacts: result.artifacts,
999
- });
1000
- }
1001
- return;
1002
- }
1003
- if (command === "label") {
1004
- const sourcePath = cli.positionals[0];
1005
- if (!sourcePath)
1006
- throw new Error("label requires <input.jsonl|input.csv>");
1007
- const configInput = await configFromArgv(argv);
1008
- const config = localRunnerConfigSchema.parse({
1009
- ...configInput,
1010
- dryRun: hasFlag(argv, "--dry-run") ? true : configInput.dryRun,
1011
- });
1012
- const format = readOption(argv, "--format");
1013
- if (format !== undefined && format !== "jsonl" && format !== "csv") {
1014
- throw new Error(`--format must be jsonl or csv, got: ${format}`);
1015
- }
1016
- let systemMessage = readOption(argv, "--system-prompt");
1017
- if (!systemMessage) {
1018
- const specPath = resolve(readOption(argv, "--spec") ?? DEFAULT_LOCAL_SPEC_PATH);
1019
- let raw;
1020
- try {
1021
- raw = await readFile(specPath, "utf8");
1022
- }
1023
- catch {
1024
- throw new Error(`Cannot read spec at ${specPath}. Pass --spec <tunedtensor.json> or --system-prompt "..." so the teacher labels under the model's instructions.`);
1025
- }
1026
- systemMessage = buildSystemMessage(localBehaviorSpecFileSchema.parse(JSON.parse(raw)));
1027
- }
1028
- const result = await runLocalLabelingJob({
1029
- sourcePath: resolve(sourcePath),
1030
- format,
1031
- inputColumn: readOption(argv, "--input-column"),
1032
- systemMessage,
1033
- config,
1034
- outputPath: readOption(argv, "--output"),
1035
- model: readOption(argv, "--model"),
1036
- reporter: createConsoleReporter({
1037
- verbose: hasFlag(argv, "--verbose"),
1038
- quiet: hasFlag(argv, "--quiet"),
1039
- }),
863
+ } : {}),
864
+ training_log: result.report.training.log_uri,
865
+ baseline_eval: result.report.artifact_uris.baseline_eval,
866
+ candidate_eval: result.report.artifact_uris.candidate_eval,
867
+ comparison: result.report.comparison,
868
+ general_regression: result.report.general_regression,
1040
869
  });
1041
- printJson(result.report);
1042
- if (result.report.status === "failed")
1043
- process.exitCode = 1;
1044
870
  return;
1045
871
  }
1046
872
  if (command === "serve") {
1047
873
  const config = await configFromArgv(argv);
1048
- const host = readOption(argv, "--host") ?? "127.0.0.1";
1049
- const port = Number(readOption(argv, "--port") ?? "8787");
1050
- const dashboard = await serveLocalDashboard({ host, port, config });
1051
- console.log(`TT Local dashboard: ${dashboard.url}`);
1052
- await new Promise((resolveStop) => {
1053
- const stop = () => {
1054
- dashboard.close().then(resolveStop, resolveStop);
1055
- };
1056
- process.once("SIGINT", stop);
1057
- process.once("SIGTERM", stop);
874
+ await serveModelTargetFromCli({
875
+ argv,
876
+ target: cli.positionals[0],
877
+ config,
1058
878
  });
1059
879
  return;
1060
880
  }
@@ -1093,42 +913,14 @@ async function main(argv) {
1093
913
  ]);
1094
914
  return printJson(compareRuns(reportA, reportB));
1095
915
  }
1096
- if (subcommand === "cancel") {
1097
- const id = cli.positionals[0];
1098
- if (!id)
1099
- throw new Error("runs cancel requires <run-id>");
1100
- await store.cancelRun(id);
1101
- const run = await store.getRun(id);
1102
- return printJson({ ok: true, run_id: run.id, status: run.status, current_stage: run.current_stage });
1103
- }
1104
- if (subcommand === "watch") {
1105
- const id = cli.positionals[0];
1106
- if (!id)
1107
- throw new Error("runs watch requires <run-id>");
1108
- const printed = new Set();
1109
- while (true) {
1110
- const events = await store.getRunEvents(id);
1111
- for (const event of events) {
1112
- if (printed.has(event.id))
1113
- continue;
1114
- printed.add(event.id);
1115
- console.log(`${event.occurred_at} ${event.stage} ${event.status}: ${event.message}`);
1116
- }
1117
- const run = await store.getRun(id);
1118
- if (isTerminalRunState(run))
1119
- return;
1120
- await sleep(1000);
1121
- }
1122
- }
1123
- if (subcommand === "reconcile") {
1124
- await store.rebuildIndexes();
1125
- return printJson({ ok: true });
1126
- }
1127
916
  throw new Error(`Unknown runs command: ${subcommand}`);
1128
917
  }
1129
918
  if (command === "models") {
1130
919
  const subcommand = cli.subcommand;
1131
- const config = await configFromArgv(argv);
920
+ const modelInputPath = subcommand === "prefetch" || subcommand === "verify-base"
921
+ ? resolve(cli.positionals[0] ?? DEFAULT_LOCAL_SPEC_PATH)
922
+ : undefined;
923
+ const config = await configFromArgv(argv, modelInputPath);
1132
924
  const store = createLocalStore(config.storeRoot);
1133
925
  if (subcommand === "list")
1134
926
  return printJson(await store.listModels());
@@ -1149,17 +941,41 @@ async function main(argv) {
1149
941
  const verified = await verifyStoredModel(model);
1150
942
  return printJson({ ok: true, model, ...verified });
1151
943
  }
1152
- if (subcommand === "prefetch" || subcommand === "verify-base") {
1153
- const inputPath = resolve(cli.positionals[0] ?? DEFAULT_LOCAL_SPEC_PATH);
1154
- const input = await loadLocalRunInput(inputPath, {
1155
- userId: readOption(argv, "--user-id"),
1156
- runNumber: readNumberOption(argv, "--run-number"),
944
+ if (subcommand === "active") {
945
+ const active = await getActiveModel(store);
946
+ return printJson({
947
+ active: active.model?.id ?? "base",
948
+ pointer: active.pointer,
949
+ model: active.model,
1157
950
  });
1158
- if (!hasFlag(argv, "--quiet")) {
1159
- for (const warning of input.warnings) {
1160
- process.stderr.write(`[tt-local] warning: ${warning}\n`);
1161
- }
951
+ }
952
+ if (subcommand === "activate") {
953
+ const id = cli.positionals[0];
954
+ if (!id)
955
+ throw new Error("models activate requires <model-id>");
956
+ const model = await store.getModel(id);
957
+ const verified = await verifyStoredModel(model);
958
+ await verifyActivationEvidence(model);
959
+ const pointer = await activateModel(store, model.id);
960
+ return printJson({
961
+ active: pointer.model_id,
962
+ pointer,
963
+ manifest_path: verified.manifest_path,
964
+ integrity: verified.integrity,
965
+ });
966
+ }
967
+ if (subcommand === "rollback") {
968
+ const current = await getActiveModel(store);
969
+ if (current.pointer?.previous_model_id) {
970
+ const previous = await store.getModel(current.pointer.previous_model_id);
971
+ await verifyStoredModel(previous);
972
+ await verifyActivationEvidence(previous);
1162
973
  }
974
+ const pointer = await rollbackActiveModel(store);
975
+ return printJson({ active: pointer.model_id ?? "base", pointer });
976
+ }
977
+ if (subcommand === "prefetch" || subcommand === "verify-base") {
978
+ const input = await loadCliBehaviorSpec(modelInputPath);
1163
979
  const report = await prefetchBaseModel({
1164
980
  request: input.request,
1165
981
  config,
@@ -1171,7 +987,6 @@ async function main(argv) {
1171
987
  });
1172
988
  return printJson({
1173
989
  ...report,
1174
- input_kind: input.kind,
1175
990
  input_path: input.path,
1176
991
  });
1177
992
  }
@@ -1179,95 +994,11 @@ async function main(argv) {
1179
994
  const id = cli.positionals[0];
1180
995
  if (!id)
1181
996
  throw new Error("models serve requires <model-id>");
1182
- const model = await store.getModel(id);
1183
- const verified = await verifyStoredModel(model, { requireServable: true });
1184
- const launch = buildLocalModelServerLaunch({
1185
- model,
1186
- config,
1187
- options: {
1188
- host: readOption(argv, "--host"),
1189
- port: readNumberOption(argv, "--port"),
1190
- device: modelServeDevice(argv),
1191
- maxTokens: readNumberOption(argv, "--max-tokens"),
1192
- temperature: readNumberOption(argv, "--temperature"),
1193
- topP: readNumberOption(argv, "--top-p"),
1194
- maxConcurrentRequests: readNumberOption(argv, "--max-concurrent-requests"),
1195
- systemPrompt: await modelSystemPrompt({ argv, model, store }),
1196
- allowRemote: hasFlag(argv, "--allow-remote"),
1197
- apiKeyEnv: readOption(argv, "--api-key-env"),
1198
- baseModelRevision: (() => {
1199
- const contract = verified.contract;
1200
- return typeof contract.base_model_revision === "string" ? contract.base_model_revision : undefined;
1201
- })(),
1202
- baseModelArtifactUri: (() => {
1203
- const contract = verified.contract;
1204
- return typeof contract.base_model_artifact_uri === "string" ? contract.base_model_artifact_uri : undefined;
1205
- })(),
1206
- },
1207
- });
1208
- if (hasFlag(argv, "--print-command")) {
1209
- return printJson({
1210
- ok: true,
1211
- model_id: model.id,
1212
- url: launch.url,
1213
- command: launch.displayCommand,
1214
- artifact_path: launch.artifactPath,
1215
- manifest_path: verified.manifest_path,
1216
- integrity: verified.integrity,
1217
- });
1218
- }
1219
- process.stderr.write(`[tt-local] verified ${verified.integrity.checked} artifact file(s)\n`);
1220
- process.stderr.write(`[tt-local] model API: ${launch.url}\n`);
1221
- await serveLocalModel(launch);
997
+ await serveModelTargetFromCli({ argv, target: id, config });
1222
998
  return;
1223
999
  }
1224
1000
  throw new Error(`Unknown models command: ${subcommand}`);
1225
1001
  }
1226
- if (command === "specs") {
1227
- const subcommand = cli.subcommand;
1228
- const config = await configFromArgv(argv);
1229
- const store = createLocalStore(config.storeRoot);
1230
- if (subcommand === "list")
1231
- return printJson(await store.listSpecs());
1232
- if (subcommand === "get") {
1233
- const id = cli.positionals[0];
1234
- if (!id)
1235
- throw new Error("specs get requires <spec-id>");
1236
- return printJson(await store.getSpec(id));
1237
- }
1238
- if (subcommand === "import") {
1239
- const path = cli.positionals[0];
1240
- if (!path)
1241
- throw new Error("specs import requires <spec-or-request.json>");
1242
- const input = JSON.parse(await readFile(resolve(path), "utf8"));
1243
- const request = fineTuneRunRequestSchema.safeParse(input);
1244
- if (request.success)
1245
- return printJson(await store.importSpec(request.data.behavior_spec_id, request.data.spec_snapshot));
1246
- const localSpec = localBehaviorSpecFileSchema.safeParse(input);
1247
- if (localSpec.success) {
1248
- const localRequest = runRequestFromLocalSpec(localSpec.data, {
1249
- userId: readOption(argv, "--user-id"),
1250
- runNumber: readNumberOption(argv, "--run-number"),
1251
- });
1252
- return printJson(await store.importSpec(localRequest.behavior_spec_id, localRequest.spec_snapshot));
1253
- }
1254
- const id = readOption(argv, "--id");
1255
- if (!id)
1256
- throw new Error("specs import requires --id when importing a raw spec snapshot");
1257
- return printJson(await store.importSpec(id, specSnapshotSchema.parse(input)));
1258
- }
1259
- throw new Error(`Unknown specs command: ${subcommand}`);
1260
- }
1261
- if (command === "store") {
1262
- const subcommand = cli.subcommand;
1263
- const config = await configFromArgv(argv);
1264
- const store = createLocalStore(config.storeRoot);
1265
- if (subcommand === "rebuild-index") {
1266
- await store.rebuildIndexes();
1267
- return printJson({ ok: true });
1268
- }
1269
- throw new Error(`Unknown store command: ${subcommand ?? ""}`);
1270
- }
1271
1002
  console.error(`Unknown command: ${command}`);
1272
1003
  process.exitCode = 1;
1273
1004
  }