open-standards/wai/tools/wai_int_synth_export.py
Transaction Science 08b1568291 wai: int_synth's weights are quantised after float training, and two engine paths moved
SPEC §5 and determinism-tiers §5 said `wai.neural.int_synth` decodes "QAT
weights". No committed model was trained that way. The trained corpus vector was
made by `tools/wai_int_synth_real.py`: float training, then post-training
quantisation with each layer's shift derived from its measured activation range,
the recipe `tools/wai_int_synth_train.py` documents. The synthetic vectors' weights
are generated from a seed. Both texts, the capability constant's comment, the
module doc and a test comment now say that.

`tools/wai_int_synth_qat.py` said it "closes the one open gap" and tunes "the
weights AND the shifts"; it trains the weights against one fixed shift and made no
committed model, and it now says so. `tools/wai_int_synth_export.py` told users to
train with QAT upstream; it now names the working path.

Stale paths: the int_synth decoder is `int_synth.rs` at the crate root, not
`neural/int_synth.rs` (SPEC §5, the export tool, the capability comment); the
quantum-ops engine is `wai-quantum/src/quantum_ops.rs`, and the quantum-sim
receipt's feature is `quantum_receipt` of `wai-quantum`, which `wai-rs` forwards,
not `["quantum", "world"]`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 11:55:53 -04:00

104 lines
4.5 KiB
Python

"""Quantize a synthesis transform to the integer `int_synth.json` spec decoded
bit-exactly by `wai-rs/src/int_synth.rs` — the integer-exact `replicate`
path.
`int_synth` runs an all-integer transposed-conv stack (no float, no ONNX), so
its decode is identical on every machine. This tool turns a trained float
synthesis (ConvTranspose2d + ReLU layers) into the integer weights + per-layer
requantize shifts the Rust decoder needs.
IMPORTANT on quality: integer fixed-point inference is bit-exact regardless, but
this tool rounds every layer with ONE fixed shift, so a float model exported here
will decode deterministically and may look much worse than it does in float — the
determinism is free, the quality is the quantisation step. The working path is
`tools/wai_int_synth_train.py` (and `wai_int_synth_real.py`, which wrote the trained
corpus vector): float training, then post-training quantisation with each layer's
shift derived from its measured activation range. `wai_int_synth_qat.py` is an
experimental attempt to train through a fixed shift; no committed model came from it.
Spec written (matches int_synth.rs):
{ "layers": [ { in_ch, out_ch, k, stride, pad, out_pad,
weight:[i32; in*out*k*k] # layout ic,oc,ky,kx
bias:[i32; out_ch], shift:u32, relu:bool } ] }
Run (needs torch):
python tools/wai_int_synth_export.py --weights synth.pt \
--out wai-web/demo/models/int_synth/int_synth.json
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import torch
import torch.nn as nn
def quantize_layer(conv: nn.ConvTranspose2d, w_bits: int, shift: int, relu: bool) -> dict:
"""Per-tensor symmetric integer quantization of a ConvTranspose2d.
weight_q = round(weight / w_scale), bias_q = round(bias / (w_scale * in_scale))
folded so the integer accumulate matches; the decoder applies `>> shift` to
requantize. We pick `w_scale` from the weight's max-abs to fill the int range.
"""
w = conv.weight.detach().cpu() # [in_ch, out_ch, kH, kW]
qmax = (1 << (w_bits - 1)) - 1
w_scale = float(w.abs().max()) / qmax if float(w.abs().max()) > 0 else 1.0
w_q = torch.clamp(torch.round(w / w_scale), -qmax - 1, qmax).to(torch.int64)
bias = conv.bias.detach().cpu() if conv.bias is not None else torch.zeros(conv.out_channels)
# Bias lives in the accumulator domain (before the >> shift).
bias_q = torch.round(bias / w_scale * (1 << shift)).to(torch.int64)
in_ch, out_ch, kh, kw = w.shape
assert kh == kw, "int_synth assumes square kernels"
sH, sW = conv.stride
pH, pW = conv.padding
opH, opW = conv.output_padding
assert sH == sW and pH == pW and opH == opW, "int_synth assumes symmetric stride/pad/out_pad"
return {
"in_ch": in_ch, "out_ch": out_ch, "k": kh,
"stride": sH, "pad": pH, "out_pad": opH,
"weight": w_q.reshape(-1).tolist(),
"bias": bias_q.reshape(-1).tolist(),
"shift": shift,
"relu": relu,
"_w_scale": w_scale, # informational
}
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--weights", required=True,
help="torch checkpoint: a Sequential of ConvTranspose2d (+ReLU between).")
ap.add_argument("--out", required=True)
ap.add_argument("--w-bits", type=int, default=8)
ap.add_argument("--shift", type=int, default=8,
help="per-layer requantize right-shift (one value for every layer).")
args = ap.parse_args()
model = torch.load(args.weights, map_location="cpu", weights_only=False)
model.eval()
convs = [m for m in model.modules() if isinstance(m, nn.ConvTranspose2d)]
if not convs:
raise SystemExit("no ConvTranspose2d layers found in the checkpoint")
# ReLU after every layer except the last (final layer emits the image).
layers = [quantize_layer(c, args.w_bits, args.shift, relu=(i < len(convs) - 1))
for i, c in enumerate(convs)]
if layers[-1]["out_ch"] != 3:
print(f"WARNING: final layer out_ch={layers[-1]['out_ch']} != 3 (RGB)")
out = Path(args.out); out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps({"layers": layers}, separators=(",", ":")))
print(f"wrote {len(layers)}-layer integer synthesis → {out} "
f"(w_bits={args.w_bits}, shift={args.shift})")
print("determinism: bit-exact on any machine by construction; "
"quality depends on the shift; see wai_int_synth_train.py for calibrated shifts.")
return 0
if __name__ == "__main__":
raise SystemExit(main())