@tuned-tensor/cli 0.15.1 → 0.17.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,110 @@
1
+ # Work from the behavior spec
2
+
3
+ Explore the [interactive architecture diagram](spec-architecture.html) for source
4
+ excerpts, code structure and command examples. Open the HTML file in a browser.
5
+
6
+ `tunedtensor.json` is the local recipe for the model's behavior, examples and
7
+ training settings. Ask the agent to improve that recipe, review the proposed
8
+ diff, approve the edit, then preview or execute a pipeline against the saved spec.
9
+ The agent reads the actual file before proposing changes; it does not use the
10
+ conversation transcript as a replacement for the current spec.
11
+
12
+ ## Review without a model call
13
+
14
+ Inside the TT shell, in your project directory:
15
+
16
+ ```text
17
+ /spec
18
+ /spec validate
19
+ /spec diff
20
+ /spec history
21
+ ```
22
+
23
+ `/spec` shows the complete current file, validation results and SHA-256.
24
+ `/spec diff` shows pending spec edits in the current conversation; otherwise it
25
+ shows the latest approved edit, or external changes since that revision.
26
+ `/spec history` lists up to 50 recent revision records. These commands require
27
+ no credentials and work before the agent is configured.
28
+
29
+ Outside the shell, use `tt spec show`, `tt spec validate`, `tt spec diff`, and
30
+ `tt spec history`. Add a workspace-relative path, for example
31
+ `tt spec show feedback/tunedtensor.json`. For continued work on a nested
32
+ project, `/cd feedback` makes its `tunedtensor.json` the current recipe.
33
+ `--json` returns structured review results. `tt spec validate` exits nonzero
34
+ for an invalid spec.
35
+
36
+ ## Edit, review, approve
37
+
38
+ ```text
39
+ › Keep my training settings, but require a single lowercase sentiment label.
40
+ [agent reads the spec and proposes a diff]
41
+ › /spec diff
42
+ › /approve
43
+ › /spec
44
+ › /spec history
45
+ ```
46
+
47
+ `get_local_spec` returns the document, validation diagnostics and content hash.
48
+ `prepare_update_local_spec` requires that hash and a patch of supported fields.
49
+ Arrays replace their whole field; `hyperparameters` and `foundation` merge by
50
+ key. Unmentioned fields remain unchanged. Identity and engine cannot change
51
+ through this edit tool. The full resulting document must pass the runtime's
52
+ strict schema, readiness checks, nonblank content checks and distinct-example
53
+ checks. Small example sets produce a warning; validation is not a claim of model
54
+ quality or GPU readiness.
55
+
56
+ Proposing an edit does not write anything. `/approve` revalidates the workspace,
57
+ parent directory, current file bytes and proposed result, acquires the spec's
58
+ writer lock and atomically replaces the file. `/reject` leaves it unchanged.
59
+ If the file changes after review, prepare a fresh edit. A saved edit invalidates
60
+ any previously prepared pipeline preview for the old spec.
61
+
62
+ Secure agent edits currently require Linux filesystem handles. Reads work on
63
+ other supported CLI platforms; edit the JSON with your editor there. Spec reads
64
+ and edits are bounded to 200 KB. Large model-facing proposals may hit the
65
+ agent's existing smaller review budget; split the request or use your editor.
66
+
67
+ Revision records live beside the spec under `.tuned-tensor/spec-history/` and
68
+ contain before/after snapshots and hashes. They can contain your training
69
+ examples. They use private permissions. History records changes made through
70
+ this edit workflow; it is not a file watcher or a complete history of external
71
+ editor changes. A `prepared` record without an `applied` record is an incomplete
72
+ journal entry: inspect the current file and its hash before attempting another
73
+ edit. A crashed writer can leave `.tunedtensor-spec.lock`; after confirming no
74
+ TT writer is still running, remove that lock manually. The lock coordinates TT
75
+ writers; avoid concurrent external editor saves during approval.
76
+
77
+ ## Execute the saved recipe
78
+
79
+ ```bash
80
+ tt spec validate
81
+ tt pipeline run --spec tunedtensor.json --dry-run
82
+ tt pipeline run --spec tunedtensor.json
83
+ ```
84
+
85
+ The default pipeline derives from the current spec. An adjacent
86
+ `tunedtensor.pipeline.json` is no longer loaded implicitly. To select an advanced
87
+ recipe explicitly, pass `--file`; a missing explicit recipe or `--spec` file fails. Foundation
88
+ training parameters in an explicit recipe must agree with the spec, including
89
+ in previews and the runtime's own execution guard. Change the spec and regenerate
90
+ the recipe when they disagree. Explicit recipes may still select supported
91
+ stages; `--only` and `--skip` remain available.
92
+
93
+ CLI planning, validation, execution and agent pipeline proposals share
94
+ `pipelineForRunInput` in `src/pipeline.ts`. It uses the same
95
+ `validateBehaviorSpec` rules as spec review and editing, defined beside the
96
+ runtime parser in `src/local-runtime/local-project.ts`. The edit module only
97
+ owns workspace access, patches and revision history.
98
+
99
+ The command reads one spec snapshot for planning, hardware warnings and execution. Plan/dry-run
100
+ JSON and final result JSON identify its path and SHA-256. The spec's compiled
101
+ instructions and examples feed training and task evaluation; the independently
102
+ configured general-regression suite keeps its own prompt. Provider choice and
103
+ GPU placement remain separate runtime choices in agent configuration and
104
+ `local-runner.json`. Evaluation policy (inference/scoring settings and the
105
+ general-regression suite) also remains in the runner config. Moving that policy
106
+ into the spec would be a separate schema change.
107
+
108
+ Agent `/approve` for a pipeline still runs a preview only. Real training uses
109
+ the direct command above. The spec is the behavior recipe; approval of a spec
110
+ edit is not approval to launch training.
@@ -0,0 +1,13 @@
1
+ {
2
+ "artifactRoot": ".tuned-tensor/artifacts",
3
+ "storeRoot": ".tuned-tensor/store",
4
+ "gpu": {
5
+ "provider": "aws",
6
+ "profile": "research",
7
+ "region": "eu-west-1",
8
+ "instanceId": "i-0123456789abcdef0",
9
+ "user": "ubuntu",
10
+ "identityFile": "~/.ssh/research-gpu.pem",
11
+ "maxSeconds": 86400
12
+ }
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuned-tensor/cli",
3
- "version": "0.15.1",
3
+ "version": "0.17.0",
4
4
  "description": "Local TT agent and CUDA fine-tuning CLI for Tuned Tensor workflows",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,7 +19,7 @@
19
19
  "test:workflows": "vitest run src/__tests__/workflows",
20
20
  "eval:agent": "tsx scripts/eval-agent.ts",
21
21
  "test:local-runtime": "node --import tsx --test \"test/local-runtime/**/*.test.ts\"",
22
- "test:python": "python3 -m unittest discover -s training/adapter/tests -p 'test_*.py' && uv run --frozen --project training/foundation python -m unittest discover -s training/foundation/tests -p 'test_*.py'",
22
+ "test:python": "python3 -m unittest discover -s training/adapter/tests -p 'test_*.py' && uv run --frozen --project training/foundation --group test python -m unittest discover -s training/foundation/tests -p 'test_*.py'",
23
23
  "test:watch": "vitest",
24
24
  "check": "npm run typecheck && npm test && npm run build"
25
25
  },
@@ -34,6 +34,8 @@
34
34
  "training/foundation/uv.lock",
35
35
  "training/foundation/src/*.py",
36
36
  "docs/local-runtime",
37
+ "docs/spec-workflow.md",
38
+ "docs/spec-architecture.html",
37
39
  "examples/local-runtime/*.json",
38
40
  "examples/local-runtime/*.jsonl"
39
41
  ],
@@ -53,9 +55,9 @@
53
55
  "@earendil-works/pi-agent-core": "0.84.1",
54
56
  "@earendil-works/pi-coding-agent": "0.84.1",
55
57
  "@tuned-tensor/pipeline-contract": "1.1.0",
56
- "chalk": "^5.6.2",
58
+ "chalk": "^6.0.0",
57
59
  "cli-table3": "^0.6.5",
58
- "commander": "^14.0.3",
60
+ "commander": "^15.0.0",
59
61
  "open": "^10.1.0",
60
62
  "ora": "^9.3.0",
61
63
  "typebox": "1.3.7",
@@ -0,0 +1,81 @@
1
+ """Export TT FoundationGPT weights to vLLM's existing GPT-2 implementation."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+ CHAT_TEMPLATE = """{%- if not messages or messages[0]['role'] != 'system' -%}
8
+ {{- '<|system|>You are a helpful assistant.<|end|>' -}}
9
+ {%- endif -%}
10
+ {%- for message in messages -%}
11
+ {%- set content = message['content'] -%}
12
+ {%- if message['role'] == 'system' -%}
13
+ {%- set content = (content | trim) or 'You are a helpful assistant.' -%}
14
+ {%- endif -%}
15
+ {%- if message['role'] not in ['system', 'user', 'assistant'] -%}
16
+ {{- raise_exception('Foundation chat supports system, user and assistant messages only.') -}}
17
+ {%- endif -%}
18
+ {{- '<|' + message['role'] + '|>' + content + '<|end|>' -}}
19
+ {%- endfor -%}
20
+ {%- if add_generation_prompt -%}{{- '<|assistant|>' -}}{%- endif -%}"""
21
+
22
+
23
+ def export_foundation(checkpoint: str, tokenizer_path: str, destination: Path) -> str:
24
+ import torch
25
+ from safetensors.torch import load_file, save_file
26
+ from tokenizers import Tokenizer
27
+ from transformers import GPT2Config, PreTrainedTokenizerFast
28
+
29
+ source = Path(checkpoint)
30
+ config = json.loads((source / 'config.json').read_text())
31
+ for key in ('depth', 'width', 'heads', 'vocab_size', 'sequence_length'):
32
+ if type(config.get(key)) is not int or config[key] <= 0:
33
+ raise ValueError(f'Invalid foundation config: {key}')
34
+ if config['width'] % config['heads']:
35
+ raise ValueError('Foundation width must be divisible by heads.')
36
+ raw_tokenizer = Tokenizer.from_file(tokenizer_path)
37
+ special_tokens = ['<unk>', '<pad>', '<|system|>', '<|user|>', '<|assistant|>', '<|end|>']
38
+ if any(raw_tokenizer.token_to_id(token) is None for token in special_tokens):
39
+ raise ValueError('Foundation tokenizer is missing required special tokens.')
40
+ tokenizer = PreTrainedTokenizerFast(tokenizer_object=raw_tokenizer,
41
+ unk_token='<unk>', pad_token='<pad>', eos_token='<|end|>',
42
+ additional_special_tokens=['<|system|>', '<|user|>', '<|assistant|>'],
43
+ model_max_length=config['sequence_length'], chat_template=CHAT_TEMPLATE)
44
+ if len(tokenizer) != config['vocab_size']:
45
+ raise ValueError('Foundation tokenizer vocabulary does not match the checkpoint.')
46
+ destination.mkdir(parents=True, exist_ok=True)
47
+ tokenizer.save_pretrained(destination)
48
+ GPT2Config(n_layer=config['depth'], n_embd=config['width'], n_head=config['heads'],
49
+ n_positions=config['sequence_length'], vocab_size=config['vocab_size'],
50
+ activation_function='gelu', layer_norm_epsilon=1e-5,
51
+ resid_pdrop=0.0, embd_pdrop=0.0, attn_pdrop=0.0,
52
+ bos_token_id=None, eos_token_id=tokenizer.eos_token_id,
53
+ pad_token_id=tokenizer.pad_token_id, architectures=['GPT2LMHeadModel'],
54
+ tie_word_embeddings=True).save_pretrained(destination)
55
+ weights = load_file(str(source / 'model.safetensors'))
56
+ exported = {}
57
+ def move(old, new, transpose=False):
58
+ tensor = weights.pop(old)
59
+ exported[new] = tensor.t().contiguous() if transpose else tensor
60
+ move('tok_emb.weight', 'transformer.wte.weight')
61
+ move('pos_emb.weight', 'transformer.wpe.weight')
62
+ head = weights.pop('lm_head.weight')
63
+ if not torch.equal(head, exported['transformer.wte.weight']):
64
+ raise ValueError('Foundation checkpoint must have tied embedding weights.')
65
+ for suffix in ('weight', 'bias'):
66
+ move('ln_f.' + suffix, 'transformer.ln_f.' + suffix)
67
+ for i in range(config['depth']):
68
+ old, new = f'blocks.{i}.', f'transformer.h.{i}.'
69
+ for src, dst in [('ln1', 'ln_1'), ('ln2', 'ln_2'),
70
+ ('mlp.0', 'mlp.c_fc'), ('mlp.2', 'mlp.c_proj')]:
71
+ for suffix in ('weight', 'bias'):
72
+ move(old + src + '.' + suffix, new + dst + '.' + suffix,
73
+ transpose=src.startswith('mlp') and suffix == 'weight')
74
+ for src, dst in [('attn.qkv', 'attn.c_attn'), ('attn.proj', 'attn.c_proj')]:
75
+ move(old + src + '.weight', new + dst + '.weight', transpose=True)
76
+ weight = exported[new + dst + '.weight']
77
+ exported[new + dst + '.bias'] = torch.zeros(weight.shape[1], dtype=weight.dtype)
78
+ if weights:
79
+ raise ValueError(f'Unexpected foundation weights: {sorted(weights)}')
80
+ save_file(exported, str(destination / 'model.safetensors'), metadata={'format': 'pt'})
81
+ return str(destination)
@@ -64,9 +64,10 @@ def build_vllm_args(model_source: str, adapter_path: str | None, temp_dir: Path)
64
64
  "Qwen/Qwen3.5-2B": "qwen3_xml",
65
65
  "nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16": "qwen3_coder",
66
66
  "meta-models/Muse-Glimmer-30B": "muse_glimmer",
67
- }[base]
68
- args += ["--enable-auto-tool-choice", "--tool-call-parser", parser]
69
- if base != "meta-models/Muse-Glimmer-30B":
67
+ }.get(base)
68
+ if parser:
69
+ args += ["--enable-auto-tool-choice", "--tool-call-parser", parser]
70
+ if parser and base != "meta-models/Muse-Glimmer-30B":
70
71
  args += ["--default-chat-template-kwargs", '{"enable_thinking":false}']
71
72
  if adapter_path:
72
73
  config = json.loads((Path(adapter_path) / "adapter_config.json").read_text())
@@ -124,11 +125,17 @@ def run_server() -> None:
124
125
  family = socket.AF_INET6 if ":" in host else socket.AF_INET
125
126
  with socket.socket(family, socket.SOCK_STREAM) as probe:
126
127
  probe.bind((host, port))
127
- model_source = prepare_model_source()
128
128
  if os.environ.get("TT_API_KEY"):
129
129
  os.environ["VLLM_API_KEY"] = os.environ["TT_API_KEY"]
130
130
  with TemporaryDirectory(prefix="tt-serve-") as temp:
131
- adapter = resolve_adapter_path(os.environ.get("TT_MODEL_ARTIFACT"), Path(temp))
131
+ if os.environ.get("TT_FOUNDATION_CHECKPOINT"):
132
+ from foundation_export import export_foundation
133
+ model_source = export_foundation(os.environ["TT_FOUNDATION_CHECKPOINT"],
134
+ os.environ["TT_FOUNDATION_TOKENIZER"], Path(temp) / "foundation")
135
+ adapter = None
136
+ else:
137
+ model_source = prepare_model_source()
138
+ adapter = resolve_adapter_path(os.environ.get("TT_MODEL_ARTIFACT"), Path(temp))
132
139
  args = build_vllm_args(model_source, adapter, Path(temp))
133
140
  from vllm.entrypoints.cli.main import main
134
141
  previous = sys.argv
@@ -282,6 +282,10 @@ def run_training(rows: list[dict[str, Any]], model_source: str) -> tuple[dict[st
282
282
  )
283
283
  result = trainer.train()
284
284
 
285
+ # Saved adapters must remain loadable after a local or remote staging directory is removed.
286
+ for adapter_config in model.peft_config.values():
287
+ adapter_config.base_model_name_or_path = str(hp("base_model", CERTIFIED_BASE_MODEL))
288
+ adapter_config.revision = hp("base_model_revision")
285
289
  model.save_pretrained(MODEL_DIR)
286
290
  tokenizer.save_pretrained(MODEL_DIR)
287
291
  return result.metrics, dtype
@@ -10,6 +10,10 @@ dependencies = [
10
10
  "torch>=2.11.0",
11
11
  ]
12
12
 
13
+ [dependency-groups]
14
+ # Match the pinned serving runtime for CPU export parity tests.
15
+ test = ["transformers==5.16.1"]
16
+
13
17
  [tool.uv.sources]
14
18
  torch = [
15
19
  { index = "pytorch-cu130", marker = "sys_platform == 'linux'" },
@@ -132,6 +132,8 @@ def generate(
132
132
  def save_model(model: FoundationGPT, directory: str | Path) -> None:
133
133
  destination = ensure_private_directory(directory)
134
134
  tensors = {key: value.detach().cpu().contiguous() for key, value in model.state_dict().items()}
135
+ # CPU tensors can still alias the tied embedding after .cpu().
136
+ tensors["lm_head.weight"] = tensors["lm_head.weight"].clone()
135
137
  model_path = destination / "model.safetensors"
136
138
  save_file(tensors, str(model_path))
137
139
  make_private_file(model_path)