weightspace 0.1.0__py3-none-any.whl

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,45 @@
1
+ """weightspace: let an LLM read and write neural network weights directly.
2
+
3
+ import weightspace as wsp
4
+ res = wsp.fix(model, "the car never gets over the ramp", llm="openai:gpt-5",
5
+ context=ENV_DOC, probes=[rollout], forward_budget=10)
6
+ print(res) # answer + how many weights changed
7
+ res.save("runs/fix-1") # transcript, result.json, sparse patch
8
+ """
9
+
10
+ from .agent import ForwardCall, Result, Session, Tool, tool
11
+ from .llm import LLM, LLMResponse, ScriptedLLM, ToolCall, get_llm
12
+ from .probes import Probe, probe
13
+ from .render import describe, fmt_array, parse_weights, render_space
14
+ from .space import DiffSummary, NumpyStore, Store, WeightSpace
15
+ from .tasks import as_weightspace, fix, improve, read, write
16
+
17
+ __version__ = "0.1.0"
18
+
19
+ __all__ = [
20
+ "WeightSpace",
21
+ "Store",
22
+ "NumpyStore",
23
+ "DiffSummary",
24
+ "Session",
25
+ "Result",
26
+ "ForwardCall",
27
+ "Tool",
28
+ "tool",
29
+ "Probe",
30
+ "probe",
31
+ "LLM",
32
+ "LLMResponse",
33
+ "ToolCall",
34
+ "ScriptedLLM",
35
+ "get_llm",
36
+ "read",
37
+ "fix",
38
+ "write",
39
+ "improve",
40
+ "as_weightspace",
41
+ "describe",
42
+ "fmt_array",
43
+ "render_space",
44
+ "parse_weights",
45
+ ]
@@ -0,0 +1,112 @@
1
+ """Adapters from real models and files to a `Store`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ import numpy as np
8
+
9
+ from .space import Store
10
+
11
+
12
+ class TorchStore(Store):
13
+ """Live view over a torch `nn.Module`'s parameters.
14
+
15
+ float32/float64/float16 CPU tensors share memory with numpy, so edits are
16
+ instant. Other dtypes (bf16, GPU tensors) are mirrored as float32 and
17
+ copied back on `flush`.
18
+ """
19
+
20
+ def __init__(self, module, *, include_buffers: bool = False, pattern: str | None = None):
21
+ import torch
22
+
23
+ self.module = module
24
+ self._torch = torch
25
+ tensors = dict(module.named_parameters())
26
+ if include_buffers:
27
+ tensors.update({k: v for k, v in module.named_buffers() if v is not None and v.is_floating_point()})
28
+ if pattern:
29
+ import fnmatch
30
+
31
+ tensors = {k: v for k, v in tensors.items() if fnmatch.fnmatch(k, pattern) or pattern in k}
32
+ self._t = tensors
33
+ self._arr: dict[str, np.ndarray] = {}
34
+ self._mirrored: set[str] = set()
35
+
36
+ def names(self):
37
+ return list(self._t)
38
+
39
+ def shape(self, name):
40
+ return tuple(self._t[name].shape)
41
+
42
+ def dtype(self, name):
43
+ return str(self._t[name].dtype).replace("torch.", "")
44
+
45
+ def get(self, name):
46
+ arr = self._arr.get(name)
47
+ if arr is None:
48
+ t = self._t[name].detach()
49
+ if t.device.type == "cpu" and t.dtype in (
50
+ self._torch.float32,
51
+ self._torch.float64,
52
+ self._torch.float16,
53
+ ):
54
+ arr = t.numpy()
55
+ else:
56
+ arr = t.to("cpu", self._torch.float32).numpy().copy()
57
+ self._mirrored.add(name)
58
+ self._arr[name] = arr
59
+ return arr
60
+
61
+ def flush(self, names):
62
+ with self._torch.no_grad():
63
+ for n in names:
64
+ if n in self._mirrored:
65
+ p = self._t[n]
66
+ p.copy_(self._torch.from_numpy(self._arr[n]).to(p.device, p.dtype))
67
+
68
+
69
+ def load_hf(model_or_name, **kw):
70
+ """Accept a loaded `transformers` model or a hub id / local path."""
71
+ if not isinstance(model_or_name, str):
72
+ return model_or_name
73
+ from transformers import AutoModelForCausalLM
74
+
75
+ kw.setdefault("torch_dtype", "auto")
76
+ return AutoModelForCausalLM.from_pretrained(model_or_name, **kw)
77
+
78
+
79
+ def load_file(path: str) -> dict[str, np.ndarray]:
80
+ ext = os.path.splitext(path)[1].lower()
81
+ if ext == ".safetensors":
82
+ from safetensors.numpy import load_file as st_load
83
+
84
+ try:
85
+ return st_load(path)
86
+ except (TypeError, ValueError):
87
+ from safetensors.torch import load_file as st_torch_load
88
+
89
+ return {k: v.float().numpy() for k, v in st_torch_load(path).items()}
90
+ if ext == ".npz":
91
+ with np.load(path) as f:
92
+ return {k: f[k] for k in f.files}
93
+ if ext in (".pt", ".pth", ".bin", ".ckpt"):
94
+ import torch
95
+
96
+ obj = torch.load(path, map_location="cpu", weights_only=True)
97
+ if isinstance(obj, dict) and "state_dict" in obj:
98
+ obj = obj["state_dict"]
99
+ return {k: v.float().numpy() for k, v in obj.items() if hasattr(v, "is_floating_point") and v.is_floating_point()}
100
+ raise ValueError(f"unsupported weights file: {path}")
101
+
102
+
103
+ def save_file(tensors: dict[str, np.ndarray], path: str) -> None:
104
+ ext = os.path.splitext(path)[1].lower()
105
+ if ext == ".safetensors":
106
+ from safetensors.numpy import save_file as st_save
107
+
108
+ st_save({k: np.ascontiguousarray(v) for k, v in tensors.items()}, path)
109
+ elif ext == ".npz":
110
+ np.savez_compressed(path, **tensors)
111
+ else:
112
+ raise ValueError(f"unsupported weights file: {path}")
weightspace/agent.py ADDED
@@ -0,0 +1,436 @@
1
+ """The agent loop: an LLM, a WeightSpace, tools, and a forward-pass budget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import time
9
+ from collections.abc import Callable, Iterable
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+
13
+ import numpy as np
14
+
15
+ from . import prompts
16
+ from .llm import LLM, get_llm, normalize_thinking
17
+ from .probes import Probe
18
+ from .render import describe, extract_blocks, fmt_array, parse_index, parse_weights, render_space
19
+ from .sandbox import Sandbox
20
+ from .space import DiffSummary, WeightSpace
21
+
22
+ EDIT_MODES = ("fix", "write", "improve")
23
+
24
+
25
+ @dataclass
26
+ class Tool:
27
+ name: str
28
+ description: str
29
+ parameters: dict
30
+ fn: Callable[..., Any]
31
+
32
+ def spec(self) -> dict:
33
+ return {"name": self.name, "description": self.description, "parameters": self.parameters}
34
+
35
+
36
+ def tool(name: str, description: str, /, **props) -> Callable[[Callable], Tool]:
37
+ """Small helper for custom tools: `tool("name", "desc", x={"type": "integer"})(fn)`."""
38
+ required = [k for k, v in props.items() if not v.pop("optional", False)]
39
+
40
+ def wrap(fn):
41
+ return Tool(name, description, {"type": "object", "properties": props, "required": required}, fn)
42
+
43
+ return wrap
44
+
45
+
46
+ @dataclass
47
+ class ForwardCall:
48
+ probe: str
49
+ arguments: dict
50
+ output: str
51
+ cost: int
52
+ seconds: float
53
+
54
+
55
+ @dataclass
56
+ class Result:
57
+ answer: str
58
+ confidence: float | None
59
+ mode: str
60
+ thinking: str | None
61
+ diff: DiffSummary
62
+ forward_calls: list[ForwardCall]
63
+ forward_budget: int
64
+ steps: int
65
+ transcript: list[dict]
66
+ usage: dict
67
+ ws: WeightSpace = field(repr=False)
68
+
69
+ @property
70
+ def forward_used(self) -> int:
71
+ return sum(c.cost for c in self.forward_calls)
72
+
73
+ def __str__(self) -> str:
74
+ conf = f" (confidence {self.confidence:.2f})" if self.confidence is not None else ""
75
+ think = f" thinking={self.thinking}" if self.thinking else ""
76
+ return (
77
+ f"[{self.mode}] {self.answer}{conf}\n"
78
+ f"steps={self.steps}{think} forward passes={self.forward_used}/{self.forward_budget}\n{self.diff}"
79
+ )
80
+
81
+ def save(self, directory: str) -> None:
82
+ os.makedirs(directory, exist_ok=True)
83
+ with open(os.path.join(directory, "transcript.jsonl"), "w") as f:
84
+ for m in self.transcript:
85
+ f.write(json.dumps(m, default=str) + "\n")
86
+ summary = {
87
+ "mode": self.mode,
88
+ "thinking": self.thinking,
89
+ "answer": self.answer,
90
+ "confidence": self.confidence,
91
+ "steps": self.steps,
92
+ "forward_used": self.forward_used,
93
+ "forward_budget": self.forward_budget,
94
+ "forward_calls": [c.__dict__ for c in self.forward_calls],
95
+ "changed_params": self.diff.changed_params,
96
+ "total_params": self.diff.total_params,
97
+ "changed_fraction": self.diff.fraction,
98
+ "tensors": [t.__dict__ for t in self.diff.tensors],
99
+ "usage": self.usage,
100
+ }
101
+ with open(os.path.join(directory, "result.json"), "w") as f:
102
+ json.dump(summary, f, indent=2, default=str)
103
+ if self.diff.changed_params:
104
+ self.ws.save_patch(os.path.join(directory, "patch.npz"))
105
+
106
+
107
+ class Session:
108
+ """One weight-space programming run.
109
+
110
+ ws: the weights (see `WeightSpace.from_torch`, `from_hf`, `from_numpy`, `from_file`)
111
+ llm: an `LLM` or a spec like "openai:gpt-5" / "anthropic:claude-sonnet-4-5"
112
+ mode: "read" | "fix" | "write" | "improve"
113
+ context: natural-language description of architecture, input/output layout, environment...
114
+ probes: forward passes the LLM may spend (see `Probe`); each call costs `probe.cost`
115
+ forward_budget: total forward-pass budget across probes
116
+ python: give the LLM a persistent Python sandbox over the weights
117
+ thinking: reasoning of the editing LLM: None = provider default, False/"off" = no thinking
118
+ (provider reasoning disabled and the LLM told to act directly), True or "low"/"medium"/"high"
119
+ """
120
+
121
+ def __init__(
122
+ self,
123
+ ws: WeightSpace,
124
+ llm: LLM | str,
125
+ *,
126
+ mode: str = "read",
127
+ context: str = "",
128
+ probes: Iterable[Probe | Callable] = (),
129
+ forward_budget: int = 0,
130
+ python: bool = True,
131
+ tools: Iterable[Tool] = (),
132
+ namespace: dict | None = None,
133
+ max_steps: int = 60,
134
+ max_tool_chars: int = 12000,
135
+ read_limit: int = 4096,
136
+ sandbox_timeout: float | None = 300,
137
+ system_prompt: str | None = None,
138
+ thinking: bool | str | None = None,
139
+ log_path: str | None = None,
140
+ verbose: bool = False,
141
+ ):
142
+ if mode not in prompts.MODES:
143
+ raise ValueError(f"mode must be one of {list(prompts.MODES)}")
144
+ self.ws = ws
145
+ self.llm = get_llm(llm)
146
+ self.mode = mode
147
+ self.context = context
148
+ self.probes = [p if isinstance(p, Probe) else Probe(p) for p in probes]
149
+ self.forward_budget = forward_budget
150
+ self.max_steps = max_steps
151
+ self.max_tool_chars = max_tool_chars
152
+ self.read_limit = read_limit
153
+ self.thinking = normalize_thinking(thinking)
154
+ if self.thinking is not None:
155
+ self.llm.thinking = self.thinking
156
+ self.system_prompt = system_prompt or prompts.SYSTEM
157
+ if self.thinking == "off":
158
+ self.system_prompt += prompts.NO_THINKING
159
+ self.log_path = log_path
160
+ self.verbose = verbose
161
+ self.forward_calls: list[ForwardCall] = []
162
+ self.usage: dict[str, float] = {}
163
+ self._answer: tuple[str, float | None] | None = None
164
+
165
+ self.can_edit = mode in EDIT_MODES
166
+ ns = {"np": np, "R": ws.R}
167
+ try:
168
+ import torch
169
+
170
+ ns["torch"] = torch
171
+ except ImportError:
172
+ pass
173
+ if self.can_edit:
174
+ ns.update(W=ws.W, diff=ws.diff, checkpoint=ws.checkpoint, restore=ws.restore, revert=ws.revert)
175
+ ns.update(namespace or {})
176
+ self.sandbox = Sandbox(ns, timeout=sandbox_timeout, max_output=max_tool_chars) if python else None
177
+ self.tools: dict[str, Tool] = {}
178
+ for t in [*self._builtin_tools(), *self._probe_tools(), *tools]:
179
+ self.tools[t.name] = t
180
+
181
+ # tools --------------------------------------------------------------------------
182
+ def _builtin_tools(self) -> list[Tool]:
183
+ ws = self.ws
184
+ out = [
185
+ tool(
186
+ "list_tensors",
187
+ "List weight tensors (name, shape, dtype, numel). Optional glob/substring filter.",
188
+ pattern={"type": "string", "optional": True},
189
+ )(self._list_tensors),
190
+ tool(
191
+ "describe_tensor", "Statistics of one tensor: quantiles, row/col norms, singular values.", name={"type": "string"}
192
+ )(lambda name: describe(name, ws[name], ws.dtype(name))),
193
+ tool(
194
+ "read_tensor",
195
+ f"Read raw values of a tensor or a numpy-style slice of it, e.g. index='0:4, :16'. "
196
+ f"At most {self.read_limit} values per call.",
197
+ name={"type": "string"},
198
+ index={"type": "string", "optional": True},
199
+ precision={"type": "integer", "optional": True},
200
+ )(self._read_tensor),
201
+ ]
202
+ if self.sandbox:
203
+ names = "np, torch, R (read-only weights)" + (
204
+ ", W (writable weights: edit in place or assign W[name] = array), diff(), checkpoint(label), "
205
+ "restore(label), revert(name=None)"
206
+ if self.can_edit
207
+ else ""
208
+ )
209
+ out.append(
210
+ tool(
211
+ "python",
212
+ f"Run Python in a persistent namespace with {names}. The last expression is printed. "
213
+ "Use it for static analysis of weights and for writing weights. It cannot run the network.",
214
+ code={"type": "string"},
215
+ )(self.sandbox.run)
216
+ )
217
+ if self.can_edit:
218
+ out += [
219
+ tool(
220
+ "write_tensor",
221
+ "Overwrite a tensor or a slice of it with values (nested list or scalar).",
222
+ name={"type": "string"},
223
+ values={"description": "nested list of numbers, or a scalar"},
224
+ index={"type": "string", "optional": True},
225
+ )(self._write_tensor),
226
+ tool("diff", "Summarise all edits made so far versus the original weights.")(lambda: str(ws.diff())),
227
+ tool("checkpoint", "Save the current weights under a label.", label={"type": "string"})(
228
+ lambda label: (ws.checkpoint(label), f"saved checkpoint {label!r}")[1]
229
+ ),
230
+ tool("restore", "Restore weights saved under a label.", label={"type": "string"})(
231
+ lambda label: (ws.restore(label), f"restored {label!r}\n{ws.diff()}")[1]
232
+ ),
233
+ tool(
234
+ "revert",
235
+ "Revert one tensor (or all, if name is omitted) to the original weights.",
236
+ name={"type": "string", "optional": True},
237
+ )(lambda name=None: (ws.revert(name), f"reverted {name or 'all tensors'}\n{ws.diff()}")[1]),
238
+ ]
239
+ out.append(
240
+ tool(
241
+ "submit",
242
+ "Finish: give the final answer (and for edits, a summary of what you changed and why).",
243
+ answer={"type": "string"},
244
+ confidence={"type": "number", "optional": True},
245
+ )(self._submit)
246
+ )
247
+ return out
248
+
249
+ def _probe_tools(self) -> list[Tool]:
250
+ out = []
251
+ for p in self.probes:
252
+
253
+ def run(_p=p, **kw):
254
+ return self._forward(_p, kw)
255
+
256
+ out.append(Tool(f"forward_{p.name}", f"[forward pass, costs {p.cost}] {p.description}", p.parameters, run))
257
+ return out
258
+
259
+ def _list_tensors(self, pattern=None):
260
+ names = self.ws.names(pattern)
261
+ lines = [f"{n} {list(self.ws.shape(n))} {self.ws.dtype(n)} {self.ws.numel(n):,}" for n in names]
262
+ total = sum(self.ws.numel(n) for n in names)
263
+ return "\n".join(lines) + f"\n{len(names)} tensors, {total:,} weights"
264
+
265
+ def _read_tensor(self, name, index=None, precision=4):
266
+ arr = self.ws[name][parse_index(index)]
267
+ arr = np.asarray(arr)
268
+ if arr.size > self.read_limit:
269
+ return f"slice has {arr.size:,} values (shape {arr.shape}); limit is {self.read_limit}. Read a smaller slice."
270
+ return f"{name}[{index or ':'}] shape={arr.shape}\n{fmt_array(arr, precision or 4)}"
271
+
272
+ def _write_tensor(self, name, values, index=None):
273
+ arr = self.ws.edit(name)
274
+ arr[parse_index(index)] = np.asarray(values, dtype=arr.dtype)
275
+ return f"wrote {name}[{index or ':'}]\n{self.ws.diff()}"
276
+
277
+ def _forward(self, p: Probe, args: dict) -> str:
278
+ remaining = self.forward_budget - sum(c.cost for c in self.forward_calls)
279
+ if p.cost > remaining:
280
+ return f"forward budget exhausted ({remaining} left, this probe costs {p.cost}). Submit your answer."
281
+ self.ws.sync()
282
+ t0 = time.time()
283
+ out = p.fn(**args)
284
+ text = out if isinstance(out, str) else json.dumps(out, indent=1, default=_jsonable)
285
+ self.forward_calls.append(ForwardCall(p.name, args, text, p.cost, time.time() - t0))
286
+ return text + f"\n[forward budget: {remaining - p.cost} of {self.forward_budget} left]"
287
+
288
+ def _submit(self, answer, confidence=None):
289
+ self._answer = (str(answer), None if confidence is None else float(confidence))
290
+ return "submitted"
291
+
292
+ # loop ---------------------------------------------------------------------------
293
+ def _intro(self, task: str) -> str:
294
+ names = self.ws.names()
295
+ shown = names[:200]
296
+ inv = "\n".join(f" {n} {list(self.ws.shape(n))} {self.ws.dtype(n)}" for n in shown)
297
+ if len(names) > len(shown):
298
+ inv += f"\n ... {len(names) - len(shown)} more (use list_tensors)"
299
+ probes = "\n".join(f" forward_{p.name} (cost {p.cost}): {p.description}" for p in self.probes) or " none"
300
+ parts = [prompts.MODES[self.mode], f"## Task\n{task}"]
301
+ if self.context:
302
+ parts.append(f"## Context\n{self.context}")
303
+ parts.append(f"## Weights ({len(names)} tensors, {self.ws.numel():,} weights)\n{inv}")
304
+ parts.append(f"## Forward passes (budget {self.forward_budget})\n{probes}")
305
+ return "\n\n".join(parts)
306
+
307
+ def _log(self, msg: dict) -> None:
308
+ if self.verbose:
309
+ role = msg["role"]
310
+ body = msg.get("content") or ""
311
+ calls = msg.get("tool_calls") or []
312
+ print(f"--- {role}{' ' + msg.get('name', '') if role == 'tool' else ''}")
313
+ if body:
314
+ print(body if len(body) < 2000 else body[:2000] + " ...")
315
+ for c in calls:
316
+ print(f">>> {c['name']}({json.dumps(c['arguments'])[:1500]})")
317
+ if self.log_path:
318
+ with open(self.log_path, "a") as f:
319
+ f.write(json.dumps(msg, default=str) + "\n")
320
+
321
+ def _add_usage(self, usage: dict) -> None:
322
+ for k, v in (usage or {}).items():
323
+ if isinstance(v, (int, float)):
324
+ self.usage[k] = self.usage.get(k, 0) + v
325
+
326
+ def _exec_tool(self, name: str, args: dict) -> str:
327
+ t = self.tools.get(name)
328
+ if t is None:
329
+ return f"unknown tool {name!r}"
330
+ try:
331
+ out = t.fn(**args)
332
+ except Exception as e: # surface errors to the LLM instead of crashing the run
333
+ out = f"{type(e).__name__}: {e}"
334
+ out = out if isinstance(out, str) else json.dumps(out, default=_jsonable)
335
+ if len(out) > self.max_tool_chars:
336
+ out = out[: self.max_tool_chars] + f"\n... [truncated {len(out) - self.max_tool_chars:,} chars]"
337
+ return out
338
+
339
+ def run(self, task: str) -> Result:
340
+ messages = [{"role": "system", "content": self.system_prompt}, {"role": "user", "content": self._intro(task)}]
341
+ for m in messages:
342
+ self._log(m)
343
+ specs = [t.spec() for t in self.tools.values()]
344
+ steps, nudges = 0, 0
345
+ while self._answer is None and steps < self.max_steps:
346
+ steps += 1
347
+ resp = self.llm.complete(messages, specs)
348
+ self._add_usage(resp.usage)
349
+ msg = resp.to_message()
350
+ messages.append(msg)
351
+ self._log(msg)
352
+ if not resp.tool_calls:
353
+ if nudges >= 2:
354
+ self._answer = (resp.text or "", None)
355
+ break
356
+ nudges += 1
357
+ messages.append({"role": "user", "content": "Continue using the tools, or call `submit` when done."})
358
+ continue
359
+ for c in resp.tool_calls:
360
+ out = self._exec_tool(c.name, c.arguments)
361
+ tmsg = {"role": "tool", "tool_call_id": c.id, "name": c.name, "content": out}
362
+ messages.append(tmsg)
363
+ self._log(tmsg)
364
+ if self._answer is None:
365
+ messages.append({"role": "user", "content": "Step limit reached. Call `submit` now."})
366
+ resp = self.llm.complete(messages, [self.tools["submit"].spec()])
367
+ self._add_usage(resp.usage)
368
+ messages.append(resp.to_message())
369
+ for c in resp.tool_calls:
370
+ if c.name == "submit":
371
+ self._exec_tool("submit", c.arguments)
372
+ if self._answer is None:
373
+ self._answer = (resp.text or "(no answer)", None)
374
+ self.ws.sync()
375
+ return self._result(messages, steps)
376
+
377
+ def run_text(self, task: str, precision: int = 3, max_chars: int = 2_000_000) -> Result:
378
+ """No tools: all weights go into the prompt, one reply comes back (Experiments 1-5 style)."""
379
+ dump = render_space(self.ws, precision)
380
+ if len(dump) > max_chars:
381
+ raise ValueError(f"weights render to {len(dump):,} chars (> {max_chars:,}); use run() with tools instead")
382
+ fmt = prompts.TEXT_MODE_EDIT if self.can_edit else prompts.TEXT_MODE_READ
383
+ user = f"{self._intro(task).split('## Weights')[0]}## Weights\n{dump}\n\n{fmt}"
384
+ messages = [{"role": "system", "content": self.system_prompt}, {"role": "user", "content": user}]
385
+ resp = self.llm.complete(messages, None)
386
+ self._add_usage(resp.usage)
387
+ messages.append(resp.to_message())
388
+ text = resp.text or ""
389
+ if self.can_edit:
390
+ notes = []
391
+ for code in extract_blocks(text, "python"):
392
+ sb = Sandbox({"np": np, "R": self.ws.R, "W": self.ws.W})
393
+ notes.append(sb.run(code))
394
+ for block in extract_blocks(text, "weights"):
395
+ for name, arr in parse_weights(block).items():
396
+ self.ws.set(name, arr.reshape(self.ws.shape(name)))
397
+ if notes:
398
+ messages.append({"role": "tool", "tool_call_id": "text-mode", "name": "python", "content": "\n".join(notes)})
399
+ self.ws.sync()
400
+ answer = _field(text, "ANSWER") or text.strip()
401
+ conf = _field(text, "CONFIDENCE")
402
+ try:
403
+ confidence = float(conf) if conf else None
404
+ except ValueError:
405
+ confidence = None
406
+ self._answer = (answer, confidence)
407
+ return self._result(messages, 1)
408
+
409
+ def _result(self, messages, steps) -> Result:
410
+ answer, conf = self._answer
411
+ return Result(
412
+ answer=answer,
413
+ confidence=conf,
414
+ mode=self.mode,
415
+ thinking=self.thinking,
416
+ diff=self.ws.diff(),
417
+ forward_calls=self.forward_calls,
418
+ forward_budget=self.forward_budget,
419
+ steps=steps,
420
+ transcript=messages,
421
+ usage=self.usage,
422
+ ws=self.ws,
423
+ )
424
+
425
+
426
+ def _field(text: str, key: str) -> str | None:
427
+ m = re.findall(rf"^\s*\**{key}\**\s*:\s*(.+)$", text, re.M | re.I)
428
+ return m[-1].strip() if m else None
429
+
430
+
431
+ def _jsonable(o):
432
+ if isinstance(o, np.ndarray):
433
+ return o.tolist()
434
+ if isinstance(o, np.generic):
435
+ return o.item()
436
+ return str(o)
weightspace/cli.py ADDED
@@ -0,0 +1,73 @@
1
+ """weightspace CLI: inspect a weights file, or ask an LLM about it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from .agent import Session
9
+ from .render import describe
10
+ from .space import WeightSpace
11
+
12
+
13
+ def main(argv=None) -> int:
14
+ ap = argparse.ArgumentParser(prog="weightspace")
15
+ sub = ap.add_subparsers(dest="cmd", required=True)
16
+
17
+ a = sub.add_parser("inspect", help="list tensors and stats (no LLM)")
18
+ a.add_argument("path")
19
+ a.add_argument("--pattern")
20
+ a.add_argument("--describe", action="store_true")
21
+
22
+ for mode in ("read", "fix", "write", "improve"):
23
+ b = sub.add_parser(mode, help=f"{mode} a weights file with an LLM")
24
+ b.add_argument("path")
25
+ b.add_argument("task")
26
+ b.add_argument("--llm", required=True, help='e.g. "openai:gpt-5" or "anthropic:claude-sonnet-4-5"')
27
+ b.add_argument("--context", default="", help="text or @file with architecture/environment notes")
28
+ b.add_argument("--text-mode", action="store_true", help="put all weights in the prompt, no tools")
29
+ b.add_argument("--max-steps", type=int, default=60)
30
+ b.add_argument(
31
+ "--thinking", choices=["off", "low", "medium", "high", "xhigh", "max"], help="reasoning of the editing LLM"
32
+ )
33
+ b.add_argument("--no-thinking", dest="thinking", action="store_const", const="off", help="same as --thinking off")
34
+ b.add_argument("--out", help="directory for transcript, result.json, patch.npz")
35
+ b.add_argument("--save-weights", help="write edited weights to .safetensors/.npz")
36
+ b.add_argument("-v", "--verbose", action="store_true")
37
+
38
+ args = ap.parse_args(argv)
39
+ ws = WeightSpace.from_file(args.path)
40
+
41
+ if args.cmd == "inspect":
42
+ for n in ws.names(args.pattern):
43
+ print(describe(n, ws[n], ws.dtype(n)) if args.describe else f"{n} {list(ws.shape(n))} {ws.dtype(n)}")
44
+ print(f"{len(ws.names(args.pattern))} tensors, {ws.numel():,} weights")
45
+ return 0
46
+
47
+ context = args.context
48
+ if context.startswith("@"):
49
+ with open(context[1:]) as f:
50
+ context = f.read()
51
+ if args.cmd == "write":
52
+ ws.zero_()
53
+ ws.commit()
54
+ s = Session(
55
+ ws,
56
+ args.llm,
57
+ mode=args.cmd,
58
+ context=context,
59
+ max_steps=args.max_steps,
60
+ thinking=args.thinking,
61
+ verbose=args.verbose,
62
+ )
63
+ res = s.run_text(args.task) if args.text_mode else s.run(args.task)
64
+ print(res)
65
+ if args.out:
66
+ res.save(args.out)
67
+ if args.save_weights:
68
+ ws.save(args.save_weights)
69
+ return 0
70
+
71
+
72
+ if __name__ == "__main__":
73
+ sys.exit(main())