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 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
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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)