weightlang 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.
- weightlang/__init__.py +17 -0
- weightlang/__main__.py +5 -0
- weightlang/artifact.py +216 -0
- weightlang/cli.py +179 -0
- weightlang/compiler.py +243 -0
- weightlang/packages.py +448 -0
- weightlang/syntax.py +476 -0
- weightlang-0.1.0.dist-info/METADATA +286 -0
- weightlang-0.1.0.dist-info/RECORD +13 -0
- weightlang-0.1.0.dist-info/WHEEL +5 -0
- weightlang-0.1.0.dist-info/entry_points.txt +2 -0
- weightlang-0.1.0.dist-info/licenses/LICENSE +21 -0
- weightlang-0.1.0.dist-info/top_level.txt +1 -0
weightlang/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""WeightLang prototype: compile .wgt network descriptions into runnable PyTorch artifacts."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .artifact import Artifact, UntrainedWeightsWarning, load_artifact # noqa: E402
|
|
6
|
+
from .syntax import Program, WgtError, parse, parse_file # noqa: E402
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Artifact",
|
|
10
|
+
"Program",
|
|
11
|
+
"UntrainedWeightsWarning",
|
|
12
|
+
"WgtError",
|
|
13
|
+
"__version__",
|
|
14
|
+
"load_artifact",
|
|
15
|
+
"parse",
|
|
16
|
+
"parse_file",
|
|
17
|
+
]
|
weightlang/__main__.py
ADDED
weightlang/artifact.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Artifact format: manifest.json + weights.safetensors + source.wgt.
|
|
2
|
+
|
|
3
|
+
Loading never unpickles anything: tensors come from safetensors and the network
|
|
4
|
+
is rebuilt from the declarative layer list in the manifest.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import struct
|
|
12
|
+
import sys
|
|
13
|
+
import warnings
|
|
14
|
+
from collections import OrderedDict
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Sequence
|
|
18
|
+
|
|
19
|
+
import torch
|
|
20
|
+
from safetensors.torch import load_file
|
|
21
|
+
from torch import nn
|
|
22
|
+
|
|
23
|
+
from .syntax import WgtError
|
|
24
|
+
|
|
25
|
+
ARTIFACT_FORMAT = "weightlang.artifact"
|
|
26
|
+
ARTIFACT_FORMAT_VERSION = 1
|
|
27
|
+
MANIFEST_FILE = "manifest.json"
|
|
28
|
+
WEIGHTS_FILE = "weights.safetensors"
|
|
29
|
+
SOURCE_FILE = "source.wgt"
|
|
30
|
+
|
|
31
|
+
# Tensor origins recorded per tensor in the manifest.
|
|
32
|
+
ORIGIN_RANDOM = "random-init"
|
|
33
|
+
ORIGIN_TRAINED = "trained"
|
|
34
|
+
ORIGIN_IMPORTED = "imported"
|
|
35
|
+
ORIGIN_IMPORTED_TRAINED = "imported+trained"
|
|
36
|
+
ORIGINS = {ORIGIN_RANDOM, ORIGIN_TRAINED, ORIGIN_IMPORTED, ORIGIN_IMPORTED_TRAINED}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class UntrainedWeightsWarning(UserWarning):
|
|
40
|
+
"""Raised when an artifact contains tensors that were only randomly initialised."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def sha256_file(path: Path) -> str:
|
|
44
|
+
h = hashlib.sha256()
|
|
45
|
+
with path.open("rb") as fh:
|
|
46
|
+
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
|
47
|
+
h.update(chunk)
|
|
48
|
+
return h.hexdigest()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def sha256_bytes(data: bytes) -> str:
|
|
52
|
+
return hashlib.sha256(data).hexdigest()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def write_safetensors(path: Path, tensors: dict[str, torch.Tensor], metadata: dict[str, str]) -> None:
|
|
56
|
+
"""Write float32 tensors in the safetensors format with a byte-stable layout.
|
|
57
|
+
|
|
58
|
+
``safetensors.torch.save_file`` does not guarantee header key order, so the
|
|
59
|
+
same tensors can produce different bytes (and hashes). Content addressing
|
|
60
|
+
needs identical bytes, so tensors and header keys are written sorted.
|
|
61
|
+
The result is read back with the official safetensors loader.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
if sys.byteorder != "little":
|
|
65
|
+
raise WgtError("writing safetensors on big-endian hosts is not supported")
|
|
66
|
+
header: dict[str, Any] = {"__metadata__": dict(sorted(metadata.items()))}
|
|
67
|
+
blobs: list[bytes] = []
|
|
68
|
+
offset = 0
|
|
69
|
+
for name in sorted(tensors):
|
|
70
|
+
t = tensors[name].detach().to("cpu").contiguous()
|
|
71
|
+
if t.dtype != torch.float32:
|
|
72
|
+
raise WgtError(f"tensor {name} must be float32, got {t.dtype}")
|
|
73
|
+
# clone() gives a storage holding exactly this tensor; no numpy needed (torch does not depend on it)
|
|
74
|
+
data = bytes(t.clone().untyped_storage())
|
|
75
|
+
header[name] = {"dtype": "F32", "shape": list(t.shape), "data_offsets": [offset, offset + len(data)]}
|
|
76
|
+
blobs.append(data)
|
|
77
|
+
offset += len(data)
|
|
78
|
+
raw = json.dumps(header, separators=(",", ":"), sort_keys=False).encode()
|
|
79
|
+
raw += b" " * (-len(raw) % 8)
|
|
80
|
+
with path.open("wb") as fh:
|
|
81
|
+
fh.write(struct.pack("<Q", len(raw)))
|
|
82
|
+
fh.write(raw)
|
|
83
|
+
for blob in blobs:
|
|
84
|
+
fh.write(blob)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_module(layers: Sequence[dict[str, Any]]) -> nn.Sequential:
|
|
88
|
+
"""Build the PyTorch module for a declarative layer list."""
|
|
89
|
+
|
|
90
|
+
modules: OrderedDict[str, nn.Module] = OrderedDict()
|
|
91
|
+
for layer in layers:
|
|
92
|
+
op = layer["op"]
|
|
93
|
+
if op == "linear":
|
|
94
|
+
modules[layer["name"]] = nn.Linear(layer["args"]["in"], layer["args"]["out"])
|
|
95
|
+
elif op == "relu":
|
|
96
|
+
modules[layer["name"]] = nn.ReLU()
|
|
97
|
+
elif op == "tanh":
|
|
98
|
+
modules[layer["name"]] = nn.Tanh()
|
|
99
|
+
elif op == "sigmoid":
|
|
100
|
+
modules[layer["name"]] = nn.Sigmoid()
|
|
101
|
+
else:
|
|
102
|
+
raise WgtError(f"unknown layer operation {op!r} in artifact")
|
|
103
|
+
return nn.Sequential(modules)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class Artifact:
|
|
108
|
+
path: Path
|
|
109
|
+
manifest: dict[str, Any]
|
|
110
|
+
module: nn.Sequential
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def name(self) -> str:
|
|
114
|
+
return self.manifest["model"]
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def untrained_tensors(self) -> list[str]:
|
|
118
|
+
"""Tensors whose values were never trained anywhere upstream."""
|
|
119
|
+
|
|
120
|
+
result = []
|
|
121
|
+
for name, meta in self.manifest["weights"]["tensors"].items():
|
|
122
|
+
if meta["origin"] == ORIGIN_RANDOM or (
|
|
123
|
+
meta["origin"] == ORIGIN_IMPORTED and meta.get("upstream_origin") == ORIGIN_RANDOM
|
|
124
|
+
):
|
|
125
|
+
result.append(name)
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
def predict(self, x: torch.Tensor | Sequence[Sequence[float]] | Sequence[float]) -> torch.Tensor:
|
|
129
|
+
"""Run inference. Accepts one feature vector or a batch ``[N, features]``."""
|
|
130
|
+
|
|
131
|
+
spec = self.manifest["interface"]["input"]
|
|
132
|
+
tensor = torch.as_tensor(x, dtype=torch.float32)
|
|
133
|
+
single = tensor.dim() == 1
|
|
134
|
+
if single:
|
|
135
|
+
tensor = tensor.unsqueeze(0)
|
|
136
|
+
if tensor.dim() != 2 or list(tensor.shape[1:]) != spec["shape"]:
|
|
137
|
+
raise WgtError(
|
|
138
|
+
f"input {spec['name']!r} expects shape [N, {spec['shape'][0]}] or [{spec['shape'][0]}], "
|
|
139
|
+
f"got {list(tensor.shape)}"
|
|
140
|
+
)
|
|
141
|
+
with torch.no_grad():
|
|
142
|
+
out = self.module(tensor)
|
|
143
|
+
return out[0] if single else out
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _check(cond: bool, message: str, path: Path) -> None:
|
|
147
|
+
if not cond:
|
|
148
|
+
raise WgtError(message, None, str(path))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def load_artifact(path: str | Path, *, warn_untrained: bool = True) -> Artifact:
|
|
152
|
+
"""Load and verify an artifact directory.
|
|
153
|
+
|
|
154
|
+
Verifies the format version, sha256 of the weights and source files, and
|
|
155
|
+
that every tensor matches the declared architecture.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
root = Path(path)
|
|
159
|
+
manifest_path = root / MANIFEST_FILE
|
|
160
|
+
_check(manifest_path.is_file(), f"not a WeightLang artifact (missing {MANIFEST_FILE})", root)
|
|
161
|
+
try:
|
|
162
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
163
|
+
except json.JSONDecodeError as exc:
|
|
164
|
+
raise WgtError(f"invalid manifest JSON: {exc}", None, str(manifest_path)) from None
|
|
165
|
+
|
|
166
|
+
_check(manifest.get("format") == ARTIFACT_FORMAT, "manifest format is not weightlang.artifact", manifest_path)
|
|
167
|
+
_check(
|
|
168
|
+
manifest.get("format_version") == ARTIFACT_FORMAT_VERSION,
|
|
169
|
+
f"unsupported artifact format_version {manifest.get('format_version')!r}; "
|
|
170
|
+
f"this loader reads version {ARTIFACT_FORMAT_VERSION}",
|
|
171
|
+
manifest_path,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
weights_meta = manifest["weights"]
|
|
175
|
+
weights_path = root / weights_meta["file"]
|
|
176
|
+
_check(weights_path.is_file(), f"missing weights file {weights_meta['file']}", root)
|
|
177
|
+
actual = sha256_file(weights_path)
|
|
178
|
+
_check(
|
|
179
|
+
actual == weights_meta["sha256"],
|
|
180
|
+
f"weights sha256 mismatch: manifest {weights_meta['sha256']}, file {actual}",
|
|
181
|
+
weights_path,
|
|
182
|
+
)
|
|
183
|
+
source_meta = manifest["source"]
|
|
184
|
+
source_path = root / source_meta["file"]
|
|
185
|
+
_check(source_path.is_file(), f"missing source file {source_meta['file']}", root)
|
|
186
|
+
_check(sha256_file(source_path) == source_meta["sha256"], "source sha256 mismatch", source_path)
|
|
187
|
+
|
|
188
|
+
module = build_module(manifest["layers"])
|
|
189
|
+
expected = module.state_dict()
|
|
190
|
+
tensors = load_file(str(weights_path))
|
|
191
|
+
_check(
|
|
192
|
+
set(tensors) == set(expected),
|
|
193
|
+
f"tensor names {sorted(tensors)} do not match architecture {sorted(expected)}",
|
|
194
|
+
weights_path,
|
|
195
|
+
)
|
|
196
|
+
_check(set(weights_meta["tensors"]) == set(expected), "manifest tensor table does not match architecture", root)
|
|
197
|
+
for name, value in tensors.items():
|
|
198
|
+
_check(
|
|
199
|
+
tuple(value.shape) == tuple(expected[name].shape),
|
|
200
|
+
f"tensor {name} has shape {list(value.shape)}, expected {list(expected[name].shape)}",
|
|
201
|
+
weights_path,
|
|
202
|
+
)
|
|
203
|
+
_check(value.dtype == torch.float32, f"tensor {name} must be float32, got {value.dtype}", weights_path)
|
|
204
|
+
_check(weights_meta["tensors"][name]["origin"] in ORIGINS, f"tensor {name} has unknown origin", root)
|
|
205
|
+
module.load_state_dict(tensors, strict=True)
|
|
206
|
+
module.eval()
|
|
207
|
+
|
|
208
|
+
artifact = Artifact(root, manifest, module)
|
|
209
|
+
if warn_untrained and artifact.untrained_tensors:
|
|
210
|
+
warnings.warn(
|
|
211
|
+
f"artifact {artifact.name!r} contains randomly initialised, untrained tensors "
|
|
212
|
+
f"({', '.join(artifact.untrained_tensors)}); its outputs are not meaningful",
|
|
213
|
+
UntrainedWeightsWarning,
|
|
214
|
+
stacklevel=2,
|
|
215
|
+
)
|
|
216
|
+
return artifact
|
weightlang/cli.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""weightc command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
import warnings
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .artifact import UntrainedWeightsWarning, load_artifact
|
|
12
|
+
from .syntax import WgtError, parse_file
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _cmd_check(args: argparse.Namespace) -> int:
|
|
16
|
+
program = parse_file(args.source)
|
|
17
|
+
params = sum(
|
|
18
|
+
layer.args["in"] * layer.args["out"] + layer.args["out"] for layer in program.layers if layer.op == "linear"
|
|
19
|
+
)
|
|
20
|
+
weights = "trained" if program.train else "random-init only (untrained)"
|
|
21
|
+
if program.imports:
|
|
22
|
+
weights += f", {len(program.imports)} imported layer(s)"
|
|
23
|
+
print(f"ok: model {program.model}, {len(program.layers)} layers, {params} parameters, weights: {weights}")
|
|
24
|
+
return 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _cmd_build(args: argparse.Namespace) -> int:
|
|
28
|
+
from .compiler import compile_file # imports torch training code lazily
|
|
29
|
+
|
|
30
|
+
result = compile_file(args.source, args.out)
|
|
31
|
+
m = result.manifest
|
|
32
|
+
print(f"built {result.path}")
|
|
33
|
+
print(f" weights sha256 {m['weights']['sha256']}")
|
|
34
|
+
origins: dict[str, int] = {}
|
|
35
|
+
for meta in m["weights"]["tensors"].values():
|
|
36
|
+
origins[meta["origin"]] = origins.get(meta["origin"], 0) + 1
|
|
37
|
+
print(" tensors " + ", ".join(f"{k}={v}" for k, v in sorted(origins.items())))
|
|
38
|
+
if m["training"]:
|
|
39
|
+
t = m["training"]
|
|
40
|
+
line = f" training loss {t['initial_loss']:.4f} -> {t['final_loss']:.4f} on {t['rows']} rows ({t['data']})"
|
|
41
|
+
if "train_accuracy" in t:
|
|
42
|
+
line += f", train accuracy {t['train_accuracy']:.2f}"
|
|
43
|
+
print(line)
|
|
44
|
+
else:
|
|
45
|
+
print(" WARNING: no train statement; weights are random and outputs are not meaningful")
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _cmd_run(args: argparse.Namespace) -> int:
|
|
50
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
51
|
+
warnings.simplefilter("always", UntrainedWeightsWarning)
|
|
52
|
+
artifact = load_artifact(args.artifact)
|
|
53
|
+
for w in caught:
|
|
54
|
+
print(f"warning: {w.message}", file=sys.stderr)
|
|
55
|
+
try:
|
|
56
|
+
data = json.loads(args.input)
|
|
57
|
+
except json.JSONDecodeError as exc:
|
|
58
|
+
raise WgtError(f"--input is not valid JSON: {exc}") from None
|
|
59
|
+
out = artifact.predict(data)
|
|
60
|
+
name = artifact.manifest["interface"]["output"]["name"]
|
|
61
|
+
print(json.dumps({name: out.tolist()}))
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _cmd_inspect(args: argparse.Namespace) -> int:
|
|
66
|
+
artifact = load_artifact(args.artifact, warn_untrained=False)
|
|
67
|
+
m = artifact.manifest
|
|
68
|
+
if args.json:
|
|
69
|
+
print(json.dumps(m, indent=2))
|
|
70
|
+
return 0
|
|
71
|
+
io = m["interface"]
|
|
72
|
+
print(
|
|
73
|
+
f"model {m['model']} (format v{m['format_version']}, wgt {m['language_version']}, weightc {m['compiler']['version']})"
|
|
74
|
+
)
|
|
75
|
+
print(f"input {io['input']['name']}: f32{io['input']['shape']}")
|
|
76
|
+
print(f"output {io['output']['name']}: f32{io['output']['shape']}")
|
|
77
|
+
print("layers " + " -> ".join(layer["name"] + ":" + layer["op"] for layer in m["layers"]))
|
|
78
|
+
print("tensors:")
|
|
79
|
+
for name, meta in m["weights"]["tensors"].items():
|
|
80
|
+
extra = f" (upstream: {meta['upstream_origin']})" if "upstream_origin" in meta else ""
|
|
81
|
+
print(f" {name:<16} {str(meta['shape']):<10} {meta['origin']}{extra}")
|
|
82
|
+
for imp in m["imports"]:
|
|
83
|
+
print(
|
|
84
|
+
f"import {imp['layer']} <- {imp['source_model']}.{imp['source_layer']} "
|
|
85
|
+
f"sha256={imp['source_weights_sha256'][:12]}… frozen={imp['frozen']}"
|
|
86
|
+
)
|
|
87
|
+
if artifact.untrained_tensors:
|
|
88
|
+
print("WARNING: untrained tensors: " + ", ".join(artifact.untrained_tensors))
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _cmd_verify(args: argparse.Namespace) -> int:
|
|
93
|
+
artifact = load_artifact(args.artifact, warn_untrained=False)
|
|
94
|
+
print(f"ok: {artifact.path} (weights sha256 {artifact.manifest['weights']['sha256']})")
|
|
95
|
+
if artifact.untrained_tensors:
|
|
96
|
+
print("note: contains untrained tensors: " + ", ".join(artifact.untrained_tensors))
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _cmd_get(args: argparse.Namespace) -> int:
|
|
101
|
+
from pathlib import Path
|
|
102
|
+
|
|
103
|
+
from .packages import SUM_FILE, get
|
|
104
|
+
|
|
105
|
+
fetched = get(Path(args.dir))
|
|
106
|
+
for item in fetched:
|
|
107
|
+
print(f"fetched and verified {item}")
|
|
108
|
+
print(f"wrote {Path(args.dir) / SUM_FILE}" if fetched else "no requirements")
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _cmd_publish(args: argparse.Namespace) -> int:
|
|
113
|
+
from pathlib import Path
|
|
114
|
+
|
|
115
|
+
from .packages import MOD_FILE, ModulePath, build_package, check_version, git_publish_checks, parse_mod
|
|
116
|
+
|
|
117
|
+
pkg = Path(args.package)
|
|
118
|
+
check_version(args.version)
|
|
119
|
+
module = ModulePath.parse(parse_mod(pkg / MOD_FILE).module)
|
|
120
|
+
commit = git_publish_checks(pkg, module)
|
|
121
|
+
index = build_package(pkg, args.version, commit, Path(args.out), allow_untrained=args.allow_untrained)
|
|
122
|
+
tag = module.tag(args.version)
|
|
123
|
+
print(f"built release assets for {module.path}@{args.version} from commit {commit}")
|
|
124
|
+
print(f" index {index}")
|
|
125
|
+
print("publish with (the commit must already be pushed):")
|
|
126
|
+
print(f" git tag {tag} {commit} && git push origin {tag}")
|
|
127
|
+
print(f" gh release create {tag} -R {module.owner}/{module.repo} --verify-tag {args.out}/*")
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def main(argv: list[str] | None = None) -> int:
|
|
132
|
+
parser = argparse.ArgumentParser(prog="weightc", description="WeightLang compiler (prototype)")
|
|
133
|
+
parser.add_argument("--version", action="version", version=f"weightc {__version__}")
|
|
134
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
135
|
+
|
|
136
|
+
p = sub.add_parser("check", help="parse and type-check a .wgt file without building")
|
|
137
|
+
p.add_argument("source")
|
|
138
|
+
p.set_defaults(func=_cmd_check)
|
|
139
|
+
|
|
140
|
+
p = sub.add_parser("build", help="compile a .wgt file into an artifact directory")
|
|
141
|
+
p.add_argument("source")
|
|
142
|
+
p.add_argument("-o", "--out", default="build", help="output directory (default: build)")
|
|
143
|
+
p.set_defaults(func=_cmd_build)
|
|
144
|
+
|
|
145
|
+
p = sub.add_parser("run", help="run inference with an artifact")
|
|
146
|
+
p.add_argument("artifact")
|
|
147
|
+
p.add_argument("--input", required=True, help='JSON vector or batch, e.g. "[[0, 1], [1, 1]]"')
|
|
148
|
+
p.set_defaults(func=_cmd_run)
|
|
149
|
+
|
|
150
|
+
p = sub.add_parser("inspect", help="show an artifact's interface and weight provenance")
|
|
151
|
+
p.add_argument("artifact")
|
|
152
|
+
p.add_argument("--json", action="store_true", help="print the raw manifest")
|
|
153
|
+
p.set_defaults(func=_cmd_inspect)
|
|
154
|
+
|
|
155
|
+
p = sub.add_parser("verify", help="check artifact hashes, tensor shapes and loadability")
|
|
156
|
+
p.add_argument("artifact")
|
|
157
|
+
p.set_defaults(func=_cmd_verify)
|
|
158
|
+
|
|
159
|
+
p = sub.add_parser("get", help="download, verify and lock the requirements in wgt.mod")
|
|
160
|
+
p.add_argument("dir", nargs="?", default=".", help="directory containing wgt.mod (default: .)")
|
|
161
|
+
p.set_defaults(func=_cmd_get)
|
|
162
|
+
|
|
163
|
+
p = sub.add_parser("publish", help="build a package's release assets and wgt.index.json")
|
|
164
|
+
p.add_argument("package", help="package directory containing wgt.mod")
|
|
165
|
+
p.add_argument("--version", required=True, help="semantic version tag, e.g. v0.1.0")
|
|
166
|
+
p.add_argument("-o", "--out", default="dist", help="empty output directory (default: dist)")
|
|
167
|
+
p.add_argument("--allow-untrained", action="store_true", help="allow artifacts with untrained tensors")
|
|
168
|
+
p.set_defaults(func=_cmd_publish)
|
|
169
|
+
|
|
170
|
+
args = parser.parse_args(argv)
|
|
171
|
+
try:
|
|
172
|
+
return args.func(args)
|
|
173
|
+
except WgtError as exc:
|
|
174
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
175
|
+
return 1
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == "__main__":
|
|
179
|
+
sys.exit(main())
|
weightlang/compiler.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""weightc compiler: .wgt program -> PyTorch weights -> artifact directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import torch
|
|
13
|
+
from torch import nn
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .artifact import (
|
|
17
|
+
ARTIFACT_FORMAT,
|
|
18
|
+
ARTIFACT_FORMAT_VERSION,
|
|
19
|
+
MANIFEST_FILE,
|
|
20
|
+
ORIGIN_IMPORTED,
|
|
21
|
+
ORIGIN_IMPORTED_TRAINED,
|
|
22
|
+
ORIGIN_RANDOM,
|
|
23
|
+
ORIGIN_TRAINED,
|
|
24
|
+
SOURCE_FILE,
|
|
25
|
+
WEIGHTS_FILE,
|
|
26
|
+
build_module,
|
|
27
|
+
load_artifact,
|
|
28
|
+
sha256_bytes,
|
|
29
|
+
sha256_file,
|
|
30
|
+
write_safetensors,
|
|
31
|
+
)
|
|
32
|
+
from .packages import is_module_import, resolve_module_import
|
|
33
|
+
from .syntax import Program, WgtError, parse_file
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class BuildResult:
|
|
38
|
+
path: Path
|
|
39
|
+
manifest: dict[str, Any]
|
|
40
|
+
module: nn.Sequential
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _layer_dicts(program: Program) -> list[dict[str, Any]]:
|
|
44
|
+
return [{"name": layer.name, "op": layer.op, "args": dict(layer.args)} for layer in program.layers]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _dataset_tensors(program: Program, name: str) -> tuple[torch.Tensor, torch.Tensor, str]:
|
|
48
|
+
ds = program.datasets[name]
|
|
49
|
+
canonical = json.dumps({"inputs": ds.inputs, "targets": ds.targets}, sort_keys=True, separators=(",", ":"))
|
|
50
|
+
return (
|
|
51
|
+
torch.tensor(ds.inputs, dtype=torch.float32),
|
|
52
|
+
torch.tensor(ds.targets, dtype=torch.float32),
|
|
53
|
+
sha256_bytes(canonical.encode()),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _apply_imports(program: Program, module: nn.Sequential, base: Path) -> tuple[list[dict], dict[str, str]]:
|
|
58
|
+
"""Copy imported tensors into ``module``. Returns import records and upstream origins."""
|
|
59
|
+
|
|
60
|
+
records: list[dict[str, Any]] = []
|
|
61
|
+
upstream: dict[str, str] = {}
|
|
62
|
+
for imp in program.imports:
|
|
63
|
+
package: dict[str, str] | None = None
|
|
64
|
+
try:
|
|
65
|
+
if is_module_import(imp.source):
|
|
66
|
+
resolved = resolve_module_import(imp.source, Path(program.path or "."))
|
|
67
|
+
src_path = resolved.path
|
|
68
|
+
package = {"module": resolved.module, "version": resolved.version, "commit": resolved.commit}
|
|
69
|
+
else:
|
|
70
|
+
src_path = (base / imp.source).resolve()
|
|
71
|
+
src = load_artifact(src_path, warn_untrained=False)
|
|
72
|
+
except WgtError as exc:
|
|
73
|
+
raise WgtError(f"cannot import {imp.target!r}: {exc}", imp.line, program.path) from None
|
|
74
|
+
weights_sha = src.manifest["weights"]["sha256"]
|
|
75
|
+
if imp.sha256 is not None and imp.sha256 != weights_sha:
|
|
76
|
+
raise WgtError(
|
|
77
|
+
f"import pin mismatch for {imp.target!r}: expected sha256={imp.sha256}, artifact has {weights_sha}",
|
|
78
|
+
imp.line,
|
|
79
|
+
program.path,
|
|
80
|
+
)
|
|
81
|
+
src_layer = next((layer for layer in src.manifest["layers"] if layer["name"] == imp.source_layer), None)
|
|
82
|
+
if src_layer is None or src_layer["op"] != "linear":
|
|
83
|
+
raise WgtError(
|
|
84
|
+
f"artifact {src.name!r} has no linear layer named {imp.source_layer!r}", imp.line, program.path
|
|
85
|
+
)
|
|
86
|
+
dst_layer = program.layer(imp.target)
|
|
87
|
+
assert dst_layer is not None
|
|
88
|
+
if src_layer["args"] != dst_layer.args:
|
|
89
|
+
raise WgtError(
|
|
90
|
+
f"incompatible import: {src.name}.{imp.source_layer} is linear(in={src_layer['args']['in']}, "
|
|
91
|
+
f"out={src_layer['args']['out']}) but {imp.target} is linear(in={dst_layer.args['in']}, "
|
|
92
|
+
f"out={dst_layer.args['out']})",
|
|
93
|
+
imp.line,
|
|
94
|
+
program.path,
|
|
95
|
+
)
|
|
96
|
+
src_state = src.module.state_dict()
|
|
97
|
+
dst_module = getattr(module, imp.target)
|
|
98
|
+
with torch.no_grad():
|
|
99
|
+
for pname in ("weight", "bias"):
|
|
100
|
+
getattr(dst_module, pname).copy_(src_state[f"{imp.source_layer}.{pname}"])
|
|
101
|
+
src_origin = src.manifest["weights"]["tensors"][f"{imp.source_layer}.{pname}"]
|
|
102
|
+
upstream[f"{imp.target}.{pname}"] = src_origin.get("upstream_origin", src_origin["origin"])
|
|
103
|
+
if imp.freeze:
|
|
104
|
+
for param in dst_module.parameters():
|
|
105
|
+
param.requires_grad_(False)
|
|
106
|
+
records.append(
|
|
107
|
+
{
|
|
108
|
+
"layer": imp.target,
|
|
109
|
+
"source": imp.source,
|
|
110
|
+
"source_model": src.name,
|
|
111
|
+
"source_layer": imp.source_layer,
|
|
112
|
+
"source_weights_sha256": weights_sha,
|
|
113
|
+
"source_manifest_sha256": sha256_file(src_path / MANIFEST_FILE),
|
|
114
|
+
"package": package,
|
|
115
|
+
"pinned": imp.sha256 is not None,
|
|
116
|
+
"frozen": imp.freeze,
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
return records, upstream
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _train(program: Program, module: nn.Sequential) -> dict[str, Any]:
|
|
123
|
+
assert program.train is not None
|
|
124
|
+
spec = program.train
|
|
125
|
+
x, y, data_sha = _dataset_tensors(program, spec.data)
|
|
126
|
+
if spec.loss == "bce":
|
|
127
|
+
if program.layers[-1].op != "sigmoid":
|
|
128
|
+
raise WgtError("loss=bce requires the last layer to be sigmoid", spec.line, program.path)
|
|
129
|
+
if not torch.all((y == 0) | (y == 1)):
|
|
130
|
+
raise WgtError("loss=bce requires targets of 0 or 1", spec.line, program.path)
|
|
131
|
+
loss_fn: nn.Module = nn.BCELoss()
|
|
132
|
+
else:
|
|
133
|
+
loss_fn = nn.MSELoss()
|
|
134
|
+
params = [p for p in module.parameters() if p.requires_grad]
|
|
135
|
+
if spec.optimizer == "adam":
|
|
136
|
+
opt: torch.optim.Optimizer = torch.optim.Adam(params, lr=spec.lr)
|
|
137
|
+
else:
|
|
138
|
+
opt = torch.optim.SGD(params, lr=spec.lr)
|
|
139
|
+
|
|
140
|
+
module.train()
|
|
141
|
+
with torch.no_grad():
|
|
142
|
+
initial_loss = float(loss_fn(module(x), y))
|
|
143
|
+
for _ in range(spec.epochs): # full-batch gradient descent: deterministic for a fixed seed
|
|
144
|
+
opt.zero_grad()
|
|
145
|
+
loss = loss_fn(module(x), y)
|
|
146
|
+
loss.backward()
|
|
147
|
+
opt.step()
|
|
148
|
+
module.eval()
|
|
149
|
+
with torch.no_grad():
|
|
150
|
+
pred = module(x)
|
|
151
|
+
final_loss = float(loss_fn(pred, y))
|
|
152
|
+
record: dict[str, Any] = {
|
|
153
|
+
"data": spec.data,
|
|
154
|
+
"data_sha256": data_sha,
|
|
155
|
+
"rows": int(x.shape[0]),
|
|
156
|
+
"loss": spec.loss,
|
|
157
|
+
"optimizer": spec.optimizer,
|
|
158
|
+
"lr": spec.lr,
|
|
159
|
+
"epochs": spec.epochs,
|
|
160
|
+
"batch": "full",
|
|
161
|
+
"initial_loss": initial_loss,
|
|
162
|
+
"final_loss": final_loss,
|
|
163
|
+
"note": "metrics are measured on the training data only; they say nothing about generalisation",
|
|
164
|
+
}
|
|
165
|
+
if spec.loss == "bce":
|
|
166
|
+
record["train_accuracy"] = float(((pred >= 0.5).float() == y).float().mean())
|
|
167
|
+
return record
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def compile_program(program: Program, out_dir: str | Path) -> BuildResult:
|
|
171
|
+
"""Build ``program`` and write its artifact to ``out_dir/<model>``."""
|
|
172
|
+
|
|
173
|
+
if program.path is None:
|
|
174
|
+
raise WgtError("compile_program needs a program parsed from a file")
|
|
175
|
+
source_path = Path(program.path)
|
|
176
|
+
base = source_path.parent
|
|
177
|
+
layers = _layer_dicts(program)
|
|
178
|
+
|
|
179
|
+
# Initialisation uses a private RNG stream so builds do not disturb caller state.
|
|
180
|
+
with torch.random.fork_rng(devices=[]):
|
|
181
|
+
torch.manual_seed(program.seed)
|
|
182
|
+
module = build_module(layers)
|
|
183
|
+
import_records, upstream = _apply_imports(program, module, base)
|
|
184
|
+
training = _train(program, module) if program.train is not None else None
|
|
185
|
+
|
|
186
|
+
imported = {r["layer"] for r in import_records}
|
|
187
|
+
frozen = {r["layer"] for r in import_records if r["frozen"]}
|
|
188
|
+
tensors_meta: dict[str, dict[str, Any]] = {}
|
|
189
|
+
for name, value in module.state_dict().items():
|
|
190
|
+
layer_name = name.split(".", 1)[0]
|
|
191
|
+
trained = training is not None and layer_name not in frozen
|
|
192
|
+
if layer_name in imported:
|
|
193
|
+
origin = ORIGIN_IMPORTED_TRAINED if trained else ORIGIN_IMPORTED
|
|
194
|
+
else:
|
|
195
|
+
origin = ORIGIN_TRAINED if trained else ORIGIN_RANDOM
|
|
196
|
+
meta: dict[str, Any] = {"shape": list(value.shape), "dtype": "f32", "origin": origin}
|
|
197
|
+
if layer_name in imported:
|
|
198
|
+
meta["upstream_origin"] = upstream[name]
|
|
199
|
+
tensors_meta[name] = meta
|
|
200
|
+
|
|
201
|
+
out_root = Path(out_dir)
|
|
202
|
+
dest = out_root / program.model
|
|
203
|
+
if dest.exists() and not (dest / MANIFEST_FILE).is_file():
|
|
204
|
+
raise WgtError(f"refusing to overwrite {dest}: it exists and is not a WeightLang artifact")
|
|
205
|
+
tmp = out_root / f".{program.model}.tmp"
|
|
206
|
+
if tmp.exists():
|
|
207
|
+
shutil.rmtree(tmp)
|
|
208
|
+
tmp.mkdir(parents=True)
|
|
209
|
+
try:
|
|
210
|
+
write_safetensors(tmp / WEIGHTS_FILE, module.state_dict(), {"format": ARTIFACT_FORMAT, "model": program.model})
|
|
211
|
+
shutil.copyfile(source_path, tmp / SOURCE_FILE)
|
|
212
|
+
manifest: dict[str, Any] = {
|
|
213
|
+
"format": ARTIFACT_FORMAT,
|
|
214
|
+
"format_version": ARTIFACT_FORMAT_VERSION,
|
|
215
|
+
"language_version": program.version,
|
|
216
|
+
"model": program.model,
|
|
217
|
+
"compiler": {"name": "weightc", "version": __version__},
|
|
218
|
+
"built_with": {"torch": torch.__version__, "python": platform.python_version()},
|
|
219
|
+
"source": {"file": SOURCE_FILE, "sha256": sha256_file(tmp / SOURCE_FILE)},
|
|
220
|
+
"interface": {
|
|
221
|
+
"input": {"name": program.input.name, "dtype": "f32", "shape": list(program.input.shape)},
|
|
222
|
+
"output": {"name": program.output.name, "dtype": "f32", "shape": list(program.output.shape)},
|
|
223
|
+
"batch": "optional leading dimension: [features] or [N, features]",
|
|
224
|
+
},
|
|
225
|
+
"layers": layers,
|
|
226
|
+
"init": {"seed": program.seed, "method": "PyTorch default nn.Linear initialisation"},
|
|
227
|
+
"imports": import_records,
|
|
228
|
+
"training": training,
|
|
229
|
+
"weights": {"file": WEIGHTS_FILE, "sha256": sha256_file(tmp / WEIGHTS_FILE), "tensors": tensors_meta},
|
|
230
|
+
}
|
|
231
|
+
(tmp / MANIFEST_FILE).write_text(json.dumps(manifest, indent=2, sort_keys=False) + "\n", encoding="utf-8")
|
|
232
|
+
if dest.exists():
|
|
233
|
+
shutil.rmtree(dest)
|
|
234
|
+
tmp.rename(dest)
|
|
235
|
+
except BaseException:
|
|
236
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
237
|
+
raise
|
|
238
|
+
module.eval()
|
|
239
|
+
return BuildResult(dest, manifest, module)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def compile_file(source: str | Path, out_dir: str | Path) -> BuildResult:
|
|
243
|
+
return compile_program(parse_file(source), out_dir)
|