# fixed-net: fixed-point MLP compression of plain tables ## Method **Chosen: 4→32→32→1 ReLU MLP, int16 weights, 2,730 bytes including axes. Report-set loss = -0.039742192, SE = 0.010629156, n = 2,048.** Negative loss means the decoded policy earns more edge than the full-table policy. This is a replica compression result, not a Solidity or engine performance claim. Credit: **Taylor (gtmcknight)** for the solved policy, tables, replica, and Gaussian reference. All upstream files were used at commit `4812a6382311624e1506aca131ca35c378f930d9`. Read the [solved README](https://raw.githubusercontent.com/gtmcknight/amm-sim/4812a6382311624e1506aca131ca35c378f930d9/research/solved/README.md) first; the authoritative inputs are [pol_mid.json](https://raw.githubusercontent.com/gtmcknight/amm-sim/4812a6382311624e1506aca131ca35c378f930d9/research/solved/pol_mid.json), [real.py](https://raw.githubusercontent.com/gtmcknight/amm-sim/4812a6382311624e1506aca131ca35c378f930d9/research/solved/real.py), [evalpol.py](https://raw.githubusercontent.com/gtmcknight/amm-sim/4812a6382311624e1506aca131ca35c378f930d9/research/solved/evalpol.py), and [replica2.py](https://raw.githubusercontent.com/gtmcknight/amm-sim/4812a6382311624e1506aca131ca35c378f930d9/research/solved/replica2.py). Measurements below are local experiments using those pinned files and the supplied payload/decoder; they are not upstream performance claims. Original `pol_mid.json` SHA256: `efbb5ee102db4d312f64565d9bf7232b6becbe02b624164f744a402bbbf2983b`. ### Table inventory Computed directly from the pinned JSON. All offsets and displacements are in bps. | Table | Shape (w × R) | Minimum | Maximum | |---|---:|---:|---:| | A0 | 321 × 13 | -24.262 | 800.000 | | B0 | 321 × 13 | -24.262 | 800.000 | | A1 | 321 × 49 | -31.323 | 800.000 | | B1 | 321 × 49 | -31.323 | 800.000 | | A2 | 321 × 49 | -31.333 | 800.000 | | B2 | 321 × 49 | -31.333 | 800.000 | There are 71,262 offset entries. Every table has 321 w rows, `w = (row−160)×5`, from −800 to +800 in 5 bps steps. Phase 0 has 13 R columns from −30 to +30 in 5 bps steps. Phases 1 and 2 have 49 R columns, `R = 5×W2L[column]`, with nonuniform spacing. In bps these are: ```text -800 -650 -500 -400 -300 -250 -200 -150 -125 -100 -85 -70 -60 -55 -50 -45 -40 -35 -30 -25 -20 -15 -10 -5 0 5 10 15 20 25 30 35 40 45 50 55 60 70 85 100 125 150 200 250 300 400 500 650 800 ``` Adjacent differences in that list are the exact column grid steps. Phase is discrete 0/1/2. There is no sd axis in the plain tables: [the reference bypasses sd when Q is absent](https://raw.githubusercontent.com/gtmcknight/amm-sim/4812a6382311624e1506aca131ca35c378f930d9/research/solved/real.py). The network nevertheless has four input slots `(w, R, phase, sd)`; all sd weights are explicitly zero. In the full JSON, every `B[phase][i][j]` equals `A[phase][320−i][ncol−1−j]` exactly (maximum discrepancy 0). Train one ask predictor and obtain bids by reflection. `VD0` and `VD1` are solver value diagnostics, not offset tables; they are not reconstructed or used by plain `real.py`. The decoder returns all six offset tables and all scalar/grid metadata that the plain reference consumes. ### Training and integer verification Local training used NumPy 2.5.4 and PyTorch CPU 2.15.0+cpu, one torch thread. Input order is `(w/100, R/100, phase, 0)`, target `A/100`, float32 MSE, Adam default options, minibatches of 2,048 sampled with replacement. Each phase has total sampling probability 1/3; within a phase each grid cell is weighted by `(0.02+exp(−abs(w)/70))×(0.02+exp(−abs(R)/70))`. Rows are flattened in row-major order, phases 0 then 1 then 2. Sampling probabilities are normalized per phase, then globally. For the chosen width 32 model: `torch.manual_seed(135)`, `numpy.random.seed(132)`, standard `Linear(4,32), ReLU, Linear(32,32), ReLU, Linear(32,1)` initialization; then overwrite neuron 0 in each hidden layer to pass `ReLU(w/100)` unchanged and set its output weight to 1. Freeze just those two weight rows, their biases, and that output weight. Other parameters remain trainable. Train 150,000 updates with `lr = 0.001 × 0.1**(update/150000)`, update starting at zero. This fixed path is part of the ordinary MLP, not an additional lookup or representation. Export weights by nearest-even rounding to each layer's fixed-point scale, biases to the corresponding accumulator scale. Start with shift 12 and reduce a layer's shift until int16 weights and int32 biases fit, without clipping. The chosen shifts are **12, 10, 12**. Set sd input weights to zero on export. All scoring uses the integer forward pass after this export, never floating-point network outputs. The complete decoded offset grid has an exact scalar/vector agreement check on 1,500 random cells (500 per phase, independent scalar `struct` reader, seed 8901). Across all grid inputs the maximum absolute pre-shift accumulator by layer is 1,044,416,228; 2,768,536,642; 540,496,130. Thus signed 32-bit accumulation is insufficient; Python uses int64 and a Solidity implementation should use signed 256-bit arithmetic. ### Gate P, before compression work Unmodified `python3 -I gates.py` reproduced [the reference gates](https://raw.githubusercontent.com/gtmcknight/amm-sim/4812a6382311624e1506aca131ca35c378f930d9/research/solved/gates.py) and the README values to every printed digit: | Seed | Perfect information | Gaussian + plain | Gaussian + qm | Lattice + qm | |---|---:|---:|---:|---:| | 10000 | 448.261189 | 443.455598 | 443.522608 | 443.663520 | | 10001 | 576.779199 | 571.463409 | 571.623551 | 571.977293 | | 10002 | 531.886497 | 525.168028 | 525.865859 | 526.411631 | ## Encoding The Data block below decodes to exactly **2,730 bytes**, all data needed for the learned lookup, including the irregular R knots. No entropy codec, float weights, external model, correction table, or submodule is required. | Offset | Length | Meaning | |---:|---:|---| | 0 | 1 | uint8 hidden width h = 32 | | 1 | 3 | uint8 layer shifts: 12, 10, 12 | | 4 | 98 | 49 little-endian signed int16 W2L coordinates, in units of 5 bps | | 102 | 256 | Layer 0 weights, int16 LE, shape 32×4, output-major row order | | 358 | 128 | Layer 0 biases, signed int32 LE, 32 elements | | 486 | 2,048 | Layer 1 weights, int16 LE, shape 32×32 | | 2,534 | 128 | Layer 1 biases, signed int32 LE, 32 elements | | 2,662 | 64 | Layer 2 weights, int16 LE, shape 1×32 | | 2,726 | 4 | Layer 2 bias, signed int32 LE | For width h with one output, total bytes = `2h² + 18h + 106`. Weights have scale `2**shift`; activations have scale `2**14`. Biases have scale `2**(14+shift)` and are already in accumulator units. For an integer grid coordinate pair, form `x = nearest_even(16384×[w/100,R/100,phase,0])`. For each layer: `z[j] = floor((bias[j] + Σ weight[j,i]×x[i]) / 2**shift)`. Apply `max(0,z)` after each hidden layer only. Final ask offset = `100×z[0]/16384` bps. An arithmetic right shift implements the floor rule; Solidity signed division truncates toward zero and is **not** interchangeable for a negative final accumulator. Input rounding is nearest-even, not truncation. To reproduce the scored policy, evaluate the MLP at the four grid corners for ask and the four reflected corners for bid, then use exactly Taylor's bilinear interpolation, grid clamping and fee conversion in [DPPolicy._fees](https://raw.githubusercontent.com/gtmcknight/amm-sim/4812a6382311624e1506aca131ca35c378f930d9/research/solved/evalpol.py). Do not substitute a continuous-state MLP evaluation: that is a different policy. Phase and sd have no interpolation axis here. The decoder's grid construction makes input rounding unambiguous. ## Tradeoff All seven settings below were scored on **the same seeds 60000..61023, n=1,024**, against the same full-table run. Loss is the mean of `full_edge − decoded_edge` per seed. SE uses sample variance divided by n. Choose the lowest mean loss; no report-set decoded scores were evaluated until that choice and its payload hash were locked. | Setting | Bytes, including axes/header | Choose-set loss | SE | |---|---:|---:|---:| | 16×16; 30k updates, constant LR | 906 | 0.351053120 | 0.016554603 | | 24×24; 100k updates, constant LR | 1690 | 0.281773385 | 0.014280382 | | 32×32; 150k updates, decaying LR | 2730 | 0.246807199 | 0.015961022 | | 24×24; 100k + 50k refinement | 1690 | 0.172467132 | 0.014762306 | | 32×32; 150k, fixed positive-w path **chosen** | 2730 | -0.056134271 | 0.015645435 | | 32×32; 120k, three phase output heads | 2866 | 0.019048078 | 0.014389945 | | 32×32; 120k, diagonal sampling/input basis | 2730 | 0.301765564 | 0.015335929 | Constant LR means 0.001. Decaying LR means `0.001×0.1**(update/updates)`. The 24-unit refinement starts from its 100k constant-LR weights and adds 50k updates with `0.0003×0.1**(update/50000)`. Baseline torch/NumPy seeds are `103+width` / `100+width`. The three-head variant is the same MLP with three linear output neurons and selects the phase's output; its extra 136 bytes are included. It was not chosen. The diagonal variant trains with second input `(R−w)/100`, absorbs that linear change into the exported first layer, and uses `R−w` instead of R in the sampling factor for phases 1/2. No other compressed representation was tested. ## Chosen **32skip: two hidden layers of 32, one scalar output, int16 weights, Q14 activations, shifts (12,10,12), 2,730 bytes.** On report seeds **10000..12047**, evaluated after selection: - Paired loss, full minus decoded: **-0.039742192**. - SE: **0.010629156**; **n = 2,048**. - Full-table mean edge: **532.385686971**. - Decoded-table mean edge: **532.425429163**. - Both requested targets are met: 2,730 < 12,288 bytes and mean loss < 0.2. Worst five seeds, sorted by largest positive loss (not by lowest raw edge): | Seed | Full edge | Decoded edge | Full − decoded | |---|---:|---:|---:| | 10632 | 693.738514195 | 689.982124361 | 3.756389833 | | 11701 | 659.900967937 | 656.701377993 | 3.199589945 | | 10822 | 760.660011206 | 758.636918851 | 2.023092355 | | 10735 | 558.244123355 | 556.578413735 | 1.665709620 | | 11705 | 642.372088335 | 640.938110678 | 1.433977657 | The report policy uses Gaussian `plain:soft=1` throughout, not perfect information or qm. Replica runs use the reference's default 10,000 steps and its per-seed randomized parameters. Scores are not engine scores, and the SE does not account for choose-set model selection. ## Lookup cost For one scalar grid-corner prediction with h=32: **1,184 integer multiplies and 1,184 accumulator additions**, 65 arithmetic rescalings, 64 ReLU comparisons. This includes all four input slots; the zero sd column could be skipped by an optimized implementation. Read 1,184 int16 weights and 65 int32 biases = **2,628 byte reads**, plus the shared four-byte header. Counting byte accesses is not counting EVM opcodes or gas. A scored ask/bid lookup using bilinear interpolation needs **eight forward passes**, because bids use reflected inputs: **9,472 multiplies, 9,472 additions, 520 rescalings, 512 ReLUs, and 21,024 learned-data byte accesses** without caching. Add header/grid reads, grid search and coordinate rounding, and bilinear interpolation (12 multiplies, six additions, shared fractions). Binary search over 49 R knots is logarithmic. Grid/header data occupy another 102 bytes and may be cached; this is not a claim that all 102 are reread each time. Gaussian filtering, logs, exponentials, fee conversion, loops, indexing and sign extension are additional costs. Direct continuous inference would need only two forward passes but has not been scored or validated here. ## Decoder One standalone function; 31 lines; standard library plus NumPy only. Returns a dictionary in the format plain `real.py` loads. The row/column arrays are generated from integer inference, not from the original tables. ```python def decode_fixed_net(data): """Decode the fixed-net payload to a dict accepted by real.py/plain.""" import numpy as np data = bytes(data) h = data[0] if not 8 <= h <= 32 or len(data) != 2*h*h + 18*h + 106: raise ValueError('invalid fixed-net payload') rgrid = np.frombuffer(data, '