A drop-in spectral analysis instrument for chess corpora. Drop a .7z
spectral corpus onto the page; the browser decompresses, parses, and indexes
everything client-side, then renders a synchronized chessboard PGN replay,
spectral lattice-fermion heatmaps (10 symmetry channels × 64 eigenmodes),
channel energy traces, and an engine eval overlay.
The site is the instrument. The .7z is the specimen. There is no
server, no build step, no pre-generated data directory.
The bytes this viewer renders are produced by the chess-spectral
encoder (pip install chess-spectral — Python reference + byte-identical C17 port), part of the
broader mlehaptics research programme that treats chess as a classical
lattice fermion system — pieces as quantum-numbered particles on a
grid-graph Laplacian, captures as field-energy redistribution on a shared
rank-5 fiber bundle. See Producing corpora for the
encoder CLI and Background for the theoretical framing.
- Open
https://lemonforest.github.io/chess-maths-the-movie/(or serve the repo root locally — see below). - Click a bundled corpus card, drop a
.7zonto the page, or click "browse". - Step through plies with
←/→,Home/End,Spaceto autoplay,1–9/0to switch games.
python3 -m http.server 8000
# open http://localhost:8000Any static file server works; there is no build step required to serve the site.
Any .7z dropped into dataset/ becomes a one-click card on the landing
screen. After adding or removing a file, regenerate the manifest:
node scripts/build-dataset-index.mjsThis writes dataset/index.json, which the viewer fetches on load to
populate the BUNDLED CORPORA list. Commit both the .7z and the
regenerated index.json.
The viewer state is encoded in the URL fragment so positions are shareable:
#game=3&ply=42&view=A1&ch=A1,FT&scale=z
Loading a URL with a fragment but no corpus shows the drop zone with a hint; after dropping the matching corpus the viewer jumps to the requested state.
Toggle the ∥F∥ button in the board panel header to paint the per-square rank-3 fiber norm for a chosen piece type as a gradient over the board. The field is a static property of the chess rules, derived from the shared fiber bundle in the research notebook §7. It does not change as you step through plies — this overlay is complementary to the existing per-channel overlay (⊞), not a replacement.
- Pawn (P): direction-collapsed approximation — pawn moves are asymmetric (directional), so the "proper" piece Laplacian isn't symmetric. For display, we build a symmetrized adjacency from the union of both colours' one-square moves (vertical step + all four capture diagonals, no two-square advance). The resulting field has only Z2×Z2 symmetry (axis reflections + 180° rotation), NOT full D4 — 90° rotation would swap vertical advances with horizontal moves, which pawns don't have. Useful as a visual, but mathematically distinct from the other pieces; the verification gates exercise the smaller symmetry group.
- Knight (N): center-bright, corners-dim. Matches the §7 qualitative 2:1 corner/center structure.
- Bishop (B): diagonal-axis symmetric.
- Rook (R): identically zero (rook rule content is in the diagonal channel, not the off-diagonal fiber; see §7b). The overlay paints a uniform neutral tint and displays a short helper note — this is expected, not a bug.
- Queen (Q): proportional to bishop (queen = bishop + rook, and rook's off-diagonal contribution is zero; the bishop-queen cosine is 1.000 by construction).
- King (K): localized pattern reflecting the king's 8-neighbourhood rule.
The overlay is a static property of each piece's move rules on an
8×8 board — it does not depend on the current position, the
occupied squares, or the ply. If you toggle between ply 2 and ply 3
with N selected, the overlay looks identical, and that's correct.
Stepping through moves changes where the knights are; it does not
change how a knight moves from any given square, and this overlay
visualizes the latter.
The most intuitive way to read it: at each square, the field measures how much "rule content" that piece carries if you placed it there, measured specifically through the shared rank-3 fiber (cross-mode coupling) of the board Laplacian. Translated into chess terms:
- Knight. Has 2 legal targets from
a1(→b3, c2), 3 froma2, 4 froma3/c1, 6 fromb3, and 8 fromd4/d5/e4/e5. The overlay shines brightest on the central 4×4 because those squares maximise knight connectivity; corners are dim because they minimise it. This is the canonical §7 example — the "2:1 corner/center" shape the notebook anchors on. - Bishop. Every bishop sees at most two diagonals from any
square. On the central
d4/d5/e4/e5squares those diagonals are 13 squares long (7 + 7 + 7 + 7 intersected); on the corners they cover only 7 squares (one long diagonal). So the field brightens towards the centre along the diagonal axes. - Rook. Every rook sees exactly 14 squares (7 along its rank + 7 along its file) from anywhere on the board — dimension- invariant. That uniformity means all of the rook's "rule content" lives in the diagonal (D₄-symmetric) spectral channel, and none in the off-diagonal fiber. Hence the overlay is flat zero and the helper line points at §7b for the proof.
- Queen. The queen's moves are the union of rook + bishop, but the rook piece is fiber-invisible (see above), so only the bishop part of the queen's rule set shows up in the fiber. Numerically this means the bishop and queen fields are parallel as 64-vectors (cosine = 1.000), just at a different overall scale — the viewer's per-piece range normalisation maps both to the full colour ramp so the shape is visible even though the absolute brightness differs.
- King. A king has 3 legal targets in the corner, 5 along an edge, and 8 in the interior. That gives a much gentler corner→centre gradient than the knight (3:8 vs. 2:8) and also fewer long-range couplings, so the king field is both smaller in range and more localised than the knight's.
The easy first sanity check from a chess perspective: count the
legal moves of the selected piece from a corner square vs. a
central square in your head (or on paper). If the overlay is darker
where you counted fewer moves and brighter where you counted more —
for N, B, Q, K — it's working. R is the principled
exception.
The sub-controls that appear when the overlay is on:
| Control | Options | Effect |
|---|---|---|
| piece | P N B R Q K | which piece type to paint (pawn is direction-collapsed — see above) |
| render | smooth / tiles | bilinear-upsampled canvas gradient vs. discrete per-square tiles |
| colormap | viridis / div / mono | perceptually uniform / divergent-around-mean / greyscale elevation |
Additionally, a follow ⇝ button appears in the chess-control row (next to the flip ⇅ button) while the fiber overlay is on. When it's active, stepping through plies auto-switches the piece selector to match whoever just moved: castling resolves to K (O-O and O-O-O), pawn moves (including captures and promotions) resolve to P, everything else reads off the leading SAN letter. On the starting position the selector holds whatever was last chosen. Turning follow on also suppresses the rook-helper note — with follow, the R piece is just one of many that'll be selected in passing, so the long explanation would be noise; the R button's native hover tooltip still carries the same text if it's wanted.
The fiber overlay (∥F∥) and the channel overlay (⊞) can be on
at the same time. They occupy different layers — fiber paints onto
a dedicated <canvas> that sits above the board squares but below
the piece sprites; channel paints each square's background-image
directly — so they compose into a two-channel visualization:
- fiber = ambient elevation (static, tells you "where does this piece's rule content live on an 8×8 board?")
- channel = localised signal spikes (dynamic, tells you "what is this piece's spectral channel doing right now?")
When both are on the fiber canvas auto-dims from alpha 0.72 to 0.42
so the channel tints stay readable through it. Pick the mono
colormap for the cleanest compose — its greyscale ramp stays out
of the channel palette's cyan/amber hue zone, so the fiber reads
as pure brightness and the channel keeps all the chroma to itself.
The fiber's tiles mode does genuinely conflict with the channel
overlay (both paint the same background-image property), so
turning channel on while fiber is in tiles auto-promotes fiber to
smooth, and picking tiles while channel is on auto-disables
channel.
Range normalization is per piece, so each piece's [min, max]
maps to the full color scale independently — this preserves visual
detail across pieces with different absolute magnitudes. The URL
hash carries fiber=<piece>,<mode>,<cmap> when the overlay is on
(with an extra ,follow suffix when auto-follow is enabled), so
you can share a specific view.
The per-square values live in data/fiber_norms.json and are
produced offline by a single script:
pip install -e '.[fiber]' # numpy
python3 scripts/generate_fiber_norms.pyThe script builds the 8×8 grid Laplacian in the tensor-product
eigenbasis, derives the rank-3 shared-fiber basis orthogonal to
rook's per-square fluctuations, and writes the 6 × 64 field with
per-piece range metadata. The V3 basis is derived from N/B/Q/K
only; pawn is projected onto the same V3 afterwards so existing
N/B/Q/K values stay stable. Verification gates enforced on write
(and re-checked by pytest -q tests/test_fiber_norms.py):
- rook field is identically zero (tol 1e-9)
- each full-symmetry piece (N/B/R/Q/K) is D4-invariant under the 8 symmetries of the square
- pawn is Z2×Z2-invariant but NOT D4-invariant (direction-collapsed adjacency — see the pawn bullet above)
- bishop-queen cosine > 0.999999
- knight corner a1 < center d4
Each corpus is a .7z archive containing:
| Path | Description |
|---|---|
manifest.json |
Master index: games list, file paths, channel means. |
pgn/game_NNN.pgn |
Standard PGN with inline [%eval] and [%clk]. |
ndjson/game_NNN.ndjson |
Per-ply records: FEN, SAN, UCI, eval, clock. |
spectralz/game_NNN.spectralz |
Gzip-compressed binary eigenmode matrix. |
HEADER (256 bytes):
bytes 0– 7 ASCII "LARTPSEC"
bytes 8–11 uint32 LE version (currently 2)
bytes 12–15 uint32 LE dimensionality (640 = 10 channels × 64 modes)
bytes 16–19 uint32 LE stride per ply record (2568 bytes)
bytes 20–23 uint32 LE number of plies
bytes 24–255 zero padding
PLY RECORDS (n_plies × 2568 bytes each, starting at byte 256):
642 float32 LE values per record
floats 0–639 spectral eigenmode values (10 channels × 64 modes)
floats 640–641 reserved padding (ignored)
Channel index → (id, label, semantics):
| idx | id | label | semantics |
|---|---|---|---|
| 0 | A1 | A₁ | D₄-invariant singlet (rotational complexity) |
| 1 | A2 | A₂ | D₄ antisymmetric singlet |
| 2 | B1 | B₁ | diagonal-reflection symmetry |
| 3 | B2 | B₂ | anti-diagonal reflection symmetry |
| 4 | E | E | 2-D irrep — total board energy |
| 5 | F1 | F₁ | fiber 1 — cross-piece interaction |
| 6 | F2 | F₂ | fiber 2 |
| 7 | F3 | F₃ | fiber 3 |
| 8 | FA | F_A | pawn antisymmetric (Z₂ symmetry breaking) |
| 9 | FD | F_D | fiber determinant — interaction topology |
Per-channel energy at a ply = Σ (eigenmode value)² over its 64 modes.
Total fiber energy (F_T) = E(F₁) + E(F₂) + E(F₃).
Chaos ratio χ (per game) = ⟨F_T⟩ / ⟨A₁⟩.
This repo is a consumer of .7z corpus archives — it does not produce
them. The encoder that makes the .spectralz files inside those archives
is published on PyPI:
pip install "chess-spectral[corpus]"The [corpus] extra pulls in python-chess for PGN ingest via the
chess_spectral.corpus module. Source, C17 port, and a parity test suite
that keeps the two implementations byte-identical live in the sibling
mlehaptics repo
— install from there instead if you want the C binary (µs/encode batch
throughput) or to develop new channels.
After install, chess-spectral is on your $PATH:
| Command | Purpose |
|---|---|
chess-spectral encode-fen --fen "<fen>" -o out.spectral |
Encode a single position to a 1-frame file. |
chess-spectral encode {-i game.ndjson | --pgn game.pgn | -u <lichess/chess.com URL>} -o game.spectralz -z |
Encode a game to a gzip-compressed .spectralz. Accepts pre-produced NDJSON, a local PGN, or a URL that returns PGN text. |
chess-spectral csv game.spectralz [-o game.csv] |
Emit the 17-column chat-friendly CSV (inter-frame metrics + channel energies). Auto-picks up a sibling .ndjson for eval/clk/NAG columns. |
chess-spectral corpus --pgn FILE [FILE ...] [--run-id NAME] [--encoder {py,c}] |
Wrap one or more local PGNs into a viewer-ready folder (manifest.json + corpus_index.csv + corpus_summary.md + pgn/ + ndjson/ + spectralz/). --encoder c uses the C binary at $CS_SPECTRAL_BIN for ~38× throughput, byte-identical output. |
chess-spectral version |
Print file-format / encoding-dim info. |
Run any subcommand with --help for the full flag set — names and defaults
in the CLI are the source of truth.
chess-spectral corpus emits a folder (manifest.json +
corpus_index.csv + corpus_summary.md + pgn/ + ndjson/ +
spectralz/); the viewer expects a .7z archive, so you have to compress
it yourself as a final step:
chess-spectral corpus --pgn my_games.pgn --run-id my_corpus --results-root .
7z a my_corpus.7z my_corpus/
# → drop my_corpus.7z onto the viewerrun_corpus_sweep.py
in the mlehaptics repo wires fetch → encode → feature-extract into one
step:
python docs/chess-maths/run_corpus_sweep.py \
--source lichess --username DrNykterstein --n 10 \
--run-id lichess_drnykterstein_$(date +%Y-%m-%d)_N10
# → results/sweep_<run-id>/{manifest.json,pgn/,ndjson/,spectralz/,corpus_index.csv}
7z a sweep_lichess_drnykterstein_$(date +%Y-%m-%d)_N10.7z \
results/sweep_lichess_drnykterstein_$(date +%Y-%m-%d)_N10/
# ↑ manual archive step — the encoder never writes .7z itself.See ENCODERS.md
for the full reproduction recipe, encoder lineage, and channel-layout
reference.
The 10 channels above are not a feature-engineering choice — they are the irreducible components of the 8×8 board Laplacian under D₄ symmetry (A₁, A₂, B₁, B₂, E) plus three shared off-diagonal fiber modes (F₁–F₃) and two pawn-specific channels (F_A antisymmetric, F_D diagonal deviation). Every piece type is uniquely classified by a 5-tuple of spectral quantum numbers; captures decompose into movement + annihilation + cross-term with exact charge-conjugation signature.
Full theoretical treatment, proofs, and computational verification:
CHESS_SPECTRAL_INSTRUCTIONS.md
and
chess_spectral_research_notebook.md
in the mlehaptics repo.
Pure static site. No framework, no bundler, no Node.
chess-maths-viewer/
├── index.html Entry point, drop zone, viewer shell
├── css/viewer.css Dark scientific-instrument theme
├── js/
│ ├── app.js State store, pub/sub, keyboard, URL hash, table, chain
│ ├── loader.js .7z extraction, .spectralz parser, NDJSON, manifest
│ ├── opfs.js Origin Private File System cache for per-game entries
│ ├── board.js chessboard.js driver, FEN sync, playback
│ ├── chess-overlay.js Paints per-square channel tint into chessboard.js squares
│ ├── fiber-overlay.js Static rank-3 fiber-norm overlay (per piece type)
│ ├── othello-board.js SVG driver for Othello corpora (swap-in for board.js)
│ ├── spectral.js Channel registry, canvas heatmap renderer
│ ├── charts.js D3 line chart, eval overlay, crosshair tooltip
│ ├── lru.js LRU eviction for parsed game state
│ └── virtual-table.js Virtual scroller for the corpus table
├── data/ Static assets generated offline (fiber_norms.json)
├── dataset/ Bundled .7z corpora + generated index.json
├── scripts/ Dev utilities (run with node ≥18)
└── lib/ Vendored JS libraries
External dependencies (CDN, no install required):
- libarchive.js 2.0.2 —
.7zdecompression - Native
DecompressionStream('gzip')—.spectralzdecompression - chessboard.js 1.0 — board rendering (FEN-driven; chess.js is not loaded — positions come straight from per-ply FENs in the NDJSON)
- jQuery 3.7.1 — required by chessboard.js 1.0
- D3 v7 — scales, axes, line generators
Two test suites run in CI (see .github/workflows/ci.yml):
- Python —
pytest -qexercises the bundledothellolibrary (tests/test_board.py,tests/test_svg.py) and the fiber-norm data file (tests/test_fiber_norms.py— rook-is-zero, D4 symmetry, bishop-queen parallel, range metadata consistency). Requires the dev extra:pip install -e '.[dev]'. - JavaScript —
npm testruns a vitest suite undertests-js/:lru.test.js— eviction order, pin safety, error tolerance ofjs/lru.js.spectral.test.js—channelEnergyForPly,getOverlayForPlyacross the four overlay transforms (abs / Δply / log / z),parseEvalString,divergingColor.virtual-table.test.js— jsdom-backed check that the virtual scroller renders fewer rows than the full dataset and keeps.activeexclusive to the matching key.opfs.test.js— Map-backed OPFS polyfill exercisesisOpfsAvailable, cache-key derivation, and read/write round-trip ofjs/opfs.js.smoke-large-corpus.test.js— reproduces the last-click-loses race on a synthetic 191 MB-shaped corpus by calling the realensureGameData/ LRU / virtual-table paths with a hand-verified 10-ply fixture (libarchive.js + WASM don't run under jsdom).
Install the dev dependencies with npm install; they stay under
node_modules/ and are not loaded by the viewer at runtime.
See LICENSE.