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.
- weightspace/__init__.py +45 -0
- weightspace/adapters.py +112 -0
- weightspace/agent.py +436 -0
- weightspace/cli.py +73 -0
- weightspace/llm/__init__.py +35 -0
- weightspace/llm/anthropic.py +125 -0
- weightspace/llm/base.py +71 -0
- weightspace/llm/mock.py +38 -0
- weightspace/llm/openai.py +108 -0
- weightspace/probes.py +60 -0
- weightspace/prompts.py +43 -0
- weightspace/render.py +151 -0
- weightspace/sandbox.py +68 -0
- weightspace/space.py +319 -0
- weightspace/tasks.py +57 -0
- weightspace-0.1.0.dist-info/METADATA +181 -0
- weightspace-0.1.0.dist-info/RECORD +19 -0
- weightspace-0.1.0.dist-info/WHEEL +4 -0
- weightspace-0.1.0.dist-info/entry_points.txt +2 -0
weightspace/__init__.py
ADDED
|
@@ -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
|
+
]
|
weightspace/adapters.py
ADDED
|
@@ -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())
|