A sunfish variant whose evaluation is classic's exact piece-square score plus a trained neural residual — with the entire accumulator and the evaluation head living in one Python integer, so a wide net costs a handful of big-int operations per node instead of a Python-level loop. The engine code packs to a few kilobytes, but the net it loads does not — see The 4k build for where that leaves the 4096-byte goal.
score(pos) = pst(pos) + clip(nn(pos), ±600)
- pst is classic sunfish's piece-square score, verbatim and exactly
incremental:
value(move)stays an exact PST delta, so move ordering, the QS stand-pat gate and futility pruning behave exactly as classic. - nn is a 768→N→1 residual,
Σ v_k (crelu(a_k(us)) − crelu(a_k(them))), with one output weight serving both perspectives — the evaluation is exactly antisymmetric, bit for bit, which the transposition table's single-value-function invariant wants.
The packed representation (packed/pnet.py documents every invariant):
- 16-bit lanes, offset-binary, a borrow-guard bit per lane; the output
weight is folded into the first-layer rows (
G_k = C·|v_k|), so the head needs no multiplies — clipped ReLU is a SWAR clamp with per-lane ceilings, and the horizontal sum is one modular reduction (2^16 ≡ 1 mod 2^16−1), made provably exact bysum(G) ≤ 65534, asserted at build time. - Quantization is certified:
excursion_bound()proves every lane stays inside the guard over all legal piece placements, andpacked/verify.pyproves lane integrity, incremental == from-scratch, engine == reference, and exact antisymmetry over thousands of random-game positions. Nothing plays a game before that battery is green. - King buckets (
--kb): first-layer rows conditioned on each side's own king (8 buckets: file pairs × back-two-ranks/advanced). A king move across a bucket boundary rebuilds the accumulator (~32 adds, rare); everything else stays incremental. - Extensions (bilinear lanes with group convolutions, a narrow odd tail, a material-phase output scale) are implemented end-to-end and verified, but currently not deployed: see the lesson below.
The artifact implements exactly the UCI subset the rules mandate —
position startpos (moves ...) — and has no FEN support: from_fen lives
in sunfish_ui/, which the packer strips.
So the artifact can only be tested against PGN (move-sequence) opening
books. Point an EPD/FEN book at it and every game silently starts from the
standard position while the GUI believes the opening was set — the engine
answers d2d4 as Black and the match measures nothing. Observed exactly that
way here; the games ended as "illegal move" only because the wrong-position
moves happened to be illegal, and a book sharing a legal move with the start
position would have produced a plausible, entirely meaningless result instead.
fastchess ... -openings file=book.pgn format=pgn order=random # correct
fastchess ... -openings file=book.epd format=epd order=random # WRONG
Screens of search or eval changes may use EPD books, because those run the dev
build through sunfish_ui/, which does parse FEN. The rule is about the packed
artifact specifically. When in doubt, check the first game's moves against the
book position before trusting the run.
tools/build/pack.sh nnue_4k/sunfish_nnue.py out.packed # engine only (needs SF_NET)
tools/build/pack_entry.sh engine.py weights out.packed # the competition artifact
pyminify + xz + a 74-byte self-extracting header (bash process
substitution: the payload decompresses straight into a /dev/fd path,
so there is no temp file, no cleanup subshell, and no chmod).
The net counts toward the 4096 bytes. An earlier version of this file claimed nets were external and outside the budget. That was wrong: under the TCEC 4k rules the entry is one file of at most 4096 bytes, and the evaluation data is part of the engine — which is why rival 4k engines such as ice4 and 4ku carry their entire evaluation as packed constants inside the limit. Moving the base tables out of the source and into the net file therefore saved nothing; it moved counted bytes from one counted place to another.
Current state, stated honestly: the engine code packs to ~3800 bytes and the smallest shipped net is 7.5 MB, so the total is roughly three orders of magnitude over the limit. The 4k goal is open, not nearly met. With the engine at its present size there would be under 300 bytes left for weights; reaching 4096 in total needs both a much smaller engine and a net compressed to the low kilobytes — extreme quantisation, weight sharing, or procedurally generated tables. The classic engine, by contrast, packs to 3196 bytes including its piece-square tables and is already within the limit.
The v1 engine (92c4746) packed to 3952 bytes.
From the TCEC 4k rules, the clauses that bind this engine:
- One file, 4096 bytes, and nothing exempts evaluation data.
- "Startup should be within 60s and not leave itself any files lying around."
So arbitrary load-time preprocessing is affordable — but a packer should use
process substitution rather than
mktemp, to leave nothing behind. - numpy is explicitly allowed for Python entries.
pypy3,xz,tail,sh,chmodare all on the allowed-commands list, and "a self decompressing shell script" is explicitly permitted — the packing approach here is legitimate. python-chess, if ever used, would count toward the size. - The required UCI subset is only
uci,uciok,isready,readyok,position startpos (moves ..),go [wtime .. btime .. winc .. binc ..],bestmove,quit— FEN parsing is not required, though unsupported commands such asstopanducinewgamemust be tolerated. - The tournament time control is 30 min + 3 s, and pondering is disabled.
One JSON header line (all scalar fields — no code execution on load;
pickles never ship), then base64 tokens, one per big int (minimal
two's-complement little-endian), in canonical order: base, gp,
ts, then piece rows (PNBRQKpnbrqk × 120; kb nets store rowsW
buckets then rowsB). The engine's loader is ~15 lines; conversion is
bit-identical (round-trip asserted in the ledger).
python packed/train_packed.py \
--data lichess_db_eval.jsonl.zst \
--N 128 --kb 8 --epochs 14 --batch 16384 --limit 30000000 \
--losspow 2.6 --factor 1 --cache quiet30M_kb8.pkl --out net.pickle
python packed/build_ext.py net.pickle net.sfnn # quantize + certify
python packed/verify.py sunfish_nnue.py net.sfnn # prove it
python packed/shapecheck.py sunfish_nnue.py net.sfnn # search-friendliness
Data is the lichess Stockfish-eval dump, quiet-filtered; training is
sigmoid-space loss (exponent 2.6) with factorized virtual features.
Float exports (--kb/--nb/--phase etc.) stay pickles internally;
everything distributed is .sfnn.
lichess/ holds the full bundle (setup.sh with frozen-tag + net-sha256
pins, config.yml, systemd unit): the bot runs this engine through
sunfish_ui/uci.py (pondering, FEN, Hash) on an ARM instance, with
the verify battery as a hard install-time gate. See lichess/README.md.