Summary
Add exact endgame play to Sanmill's Rust/TGF engine by integrating the Malom
Perfect Database (Gévay & Danner), beginning with a single embedded sector for
the 3v3 flying endgame (std_3_3_0_0.sec2, ~616 KB). The reader can be ported
from the C++ PerfectDB that already exists on the master branch
(src/perfect/). Board symmetry is handled internally by the DB's hashing, so it
comes "for free" with this integration.
This issue also documents a careful study of the NMM_LLM project, because that
project independently demonstrated the value of an exact 3v3 table and several
other ideas worth discussing openly. The analysis below is meant as a constructive
comparison of two legitimate design points, not a judgement of either.
Background: a deep look at NMM_LLM's approach
NMM_LLM is a well-engineered, ~25k-line Python Nine Men's Morris engine with a
web GUI, a learning layer, and an LLM commentary layer. Reading its code and its
excellent AI_INTERNALS.md was genuinely instructive. The points below are an
honest assessment of what it does and how it maps (or does not map) onto Sanmill's
constraints.
1. A complete, textbook search stack
ai/game_ai.py implements essentially the full modern alpha-beta toolkit:
negamax + alpha-beta, iterative deepening with time budgets, a Zobrist
transposition table with depth-preferred replacement, two killer moves per ply, a
history heuristic (depth² increments), PVS/NegaScout, Late Move Reductions (last
~60% of moves at depth ≥ 4, with captures and opponent-mill-blocking squares
excluded from reduction), aspiration windows, quiescence search over captures, and
selective search extensions (own mill threat or opponent fork → +1 ply). It also
probes its endgame table and a learned full-game DB from inside the search.
This is a clean, complete implementation and a good reference for what a full
stack looks like. Importantly for Sanmill, though, some of these techniques are
not universally beneficial — see "Lessons already learned" below.
2. A large, knowledge-rich static evaluator
The standout characteristic is ai/heuristics.py: ~2,784 lines. evaluate()
computes on the order of 40 features per leaf (each typically once per side):
closed mills, blocked pieces, two-configs, double-mill pivots, mobility, mill
threats, positional/cross-node value, cycling-mill setups, fork threats,
encirclement/herding, near-blocked "squeeze" pressure, mill-wrapping, fly
asymmetry, open-mill domination, sealed two-configs, assembly gradients (step
1–4), one-config approach, locked-mill penalties, interpose/perp-block,
convergence clusters (with bipartite matching), and more — all phase-weighted
(place/move/fly). On top of that, tactical_move_bonus() adds ~30 root-only move
bonuses, and _placement_chain_scan() performs a 4-ply forward scan inside the
evaluation path.
This is impressive domain knowledge, and in NMM_LLM's context it is a reasonable
choice:
- The engine targets a human-facing teaching/UX experience. The same features that
drive the score also serve as explanations ("this move sets up a cycling
mill", surfaced via the bonus breakdown and the LLM layer). A rich evaluator is
partly a narration layer, not only a strength layer.
- Python's node throughput is comparatively low, so the marginal search depth
bought by a leaner evaluator is smaller; investing in per-node knowledge is a
rational trade at that throughput.
It is worth noting transparently that the git history (B-1 … B-64) shows this
path carries a real maintenance cost: a number of commits fix interactions
between heuristics (e.g. capping fly-phase mobility so fly-entry doesn't score
negatively; restoring a convergence term when a mill closes; detecting
"cycling-capture self-unblock"). The authors handled these carefully — the point
is simply that a large evaluator has a large surface area of subtle interactions.
3. An exact 3v3 retrograde endgame table (the most transferable idea)
ai/endgame_solved_db.py + tools/build_endgame_db.py retrograde-solve all
3v3 flying positions into an exact WDL table. The engineering is tidy:
- Combinatorial number system encoding:
C(24,3) × C(21,3) × 2 = 5,383,840
positions, packed at 2 bits/slot → ~1.3 MB total.
- D4 dihedral symmetry reduces the solve to ~1/8 of positions (canonical
bitmask-minimum class), with a final fill pass so runtime lookups need no
canonicalization.
- The table is probed inside negamax and returns an exact
±(INF − depth).
This validated, for us, that an exact 3v3 verdict is high-leverage and cleanly
separable. Sanmill can take the idea and go one step further by using the
already-solved Malom database rather than self-solving (details below).
4. Reusable D4 symmetry engineering
ai/board_symmetry.py provides canonicalization + inverse-transform helpers
(canonical_board_str, canonical_sequence, SYM_INVERSE) shared across the
endgame table, the trajectory DB, and the opening book — a clean "store canonical,
query 8 equivalents, inverse-transform the move back" pattern. This is a good
template for any persisted book/learning data.
5. Learning subsystems and opening variety
TrajectoryDB indexes games by move-prefix and nudges scores by win/loss deltas
(with a hard-ban sentinel for user-flagged blunders); FullGameDB stores learned
outcomes; OpeningBook.select_opening() uses UCB1 with temperature-weighted
sampling for opening variety. These are primarily UX / human-engagement
features (variety, adaptivity, teaching) rather than raw strength, and they are
nicely done for that purpose.
6. LLM commentary layer
mills_llm.py / coordinator.py provide natural-language commentary and an
optional "override" suggestion path. (Sanmill also has LLM-related code; in both
projects the experience depends heavily on the underlying model maturing.)
7. The core architectural trade-off
NMM_LLM sits at the "heavy knowledge, moderate search" design point, which fits
Python + a teaching/UX product. Sanmill sits at the "light evaluation, very fast
deep search" point, which fits a monomorphized Rust engine doing millions of
nodes/sec. For reference, Sanmill's static evaluator is roughly:
value = mobility_diff + 5·(in_hand_diff + on_board_diff + removals_diff) plus a
mills-piece-count term in the relevant placing variant and a game-over branch
(crates/tgf-mill/src/rules/evaluation.rs, game_impls.rs).
Both are valid; they optimize different objectives under different runtimes. The
key takeaway for Sanmill is not "port NMM_LLM's evaluator", but "borrow the
separable, exact parts (endgame table, symmetry pattern) and keep our evaluator
lean so search stays fast."
What this means for Sanmill
- Adopt the highest-leverage, zero-hot-path-cost idea: exact endgame knowledge.
Instead of self-solving, integrate the already-exact Malom database — the C++
reader was previously in this repo on master (src/perfect/).
- Start with one sector (
std_3_3_0_0.sec2, ~616 KB) to prove the full
pipeline (format → index/symmetry mapping → probe → search short-circuit).
- Do not migrate the heavy evaluator. In a deep-search Rust engine, most of
those features are (a) redundant with what search already discovers and (b) a
large per-node cost that would reduce nps and likely weaken play. Tactics
belong in search; the evaluator stays lean.
Sector naming (Malom standard): std_<WB>_<BB>_<WH>_<BH>.sec2 → std_3_3_0_0 is
3-vs-3 on board, none in hand = the flying endgame.
Proposed approach (PerfectDB, single sector first)
- Port the PerfectDB reader (not the solver) from
master:src/perfect/
(perfect_sector.*, perfect_sec_val.*, perfect_hash.*,
perfect_sector_graph.*, plus the perfect_api.* / perfect_adaptor.* glue).
For a single-sector probe we only need the subset that decodes a .sec2 sector
and answers a value/WDL query.
- Prefer a pure-Rust reader for the one sector to keep the workspace Rust-only
and avoid a C++ build dependency; fall back to a thin FFI shim only if
reimplementing the format is impractical.
- Embed
std_3_3_0_0.sec2 as a build asset (or wire an optional-download loader
mirroring master's UX), and expose probe(state) -> Option<Wdl> (plus best
continuation if available).
- Wire the probe into the searcher's existing 3v3 fly-endgame branch: on a hit,
return the exact verdict and short-circuit; on a miss, fall through to normal
search.
Important notes & caveats
- Symmetry is internal to the DB. Malom canonicalizes via the board symmetry
group; we must map a Sanmill MillState into the DB's canonical index correctly
and must not add a parallel symmetry layer for this feature. This mapping is the
main correctness risk — cover it with tests, using master:src/perfect/ as the
reference of truth.
- Index / encoding mapping. Malom has its own square indexing and
side-to-move / in-hand conventions; the port must translate Sanmill's square
layout, side_to_move, and on-board/in-hand counts exactly.
- Scope = reader, not solver. We read a precomputed sector; we do not
regenerate the database.
- Variant guard. PerfectDB is standard-rules only. The probe must be a no-op
for non-standard variants (diagonal lines, board-full rules, Lasker, etc.).
- No hot-path regression. The probe guard for non-hitting positions must be
cheap enough that normal search nps is unaffected.
Out of scope / lessons already learned
- Killer moves / history heuristic (depth²). Already tried in Sanmill and
regressed throughput — Mill's rules are simple and the bookkeeping cost
outweighed the ordering benefit. Do not reintroduce (even though NMM_LLM uses
them effectively at Python throughput; this is exactly a design-point difference).
- Semantic move-ordering buckets (e.g. "block opponent's last escape square",
"block interconnected double mills"). Interesting, but suspected to hurt nps
given how cheap each node already is. Any such change must be proven by a
self-play / benchmark A/B — which requires the self-play harness to be solid
first. Treated as a gated follow-up, not part of this issue.
- Porting the heavy evaluator. Out of scope by design (see analysis above).
- LLM commentary layer. Sanmill already has LLM code; experience is mediocre
until the model side matures. Out of scope here.
Tasks
Phase 0 — Prerequisites
Phase 1 — PerfectDB reader port
Phase 2 — Embed sector & probe API
Phase 3 — Search integration
Phase 4 — Validation
Phase 5 — Docs
Follow-ups (separate issues)
Acknowledgments
- Gábor E. Gévay and Gábor Danner — the Malom Perfect Database and the
paper Calculating Ultra-Strong and Extended Solutions for Nine Men's Morris,
Morabaraba, and Lasker Morris.
- The NMM_LLM project and its author — for a clear, well-documented
demonstration of an exact 3v3 retrograde table, clean D4 symmetry engineering,
and a thorough write-up (AI_INTERNALS.md) that made this comparison easy and
enjoyable to do.
Summary
Add exact endgame play to Sanmill's Rust/TGF engine by integrating the Malom
Perfect Database (Gévay & Danner), beginning with a single embedded sector for
the 3v3 flying endgame (
std_3_3_0_0.sec2, ~616 KB). The reader can be portedfrom the C++ PerfectDB that already exists on the
masterbranch(
src/perfect/). Board symmetry is handled internally by the DB's hashing, so itcomes "for free" with this integration.
This issue also documents a careful study of the
NMM_LLMproject, because thatproject independently demonstrated the value of an exact 3v3 table and several
other ideas worth discussing openly. The analysis below is meant as a constructive
comparison of two legitimate design points, not a judgement of either.
Background: a deep look at NMM_LLM's approach
NMM_LLMis a well-engineered, ~25k-line Python Nine Men's Morris engine with aweb GUI, a learning layer, and an LLM commentary layer. Reading its code and its
excellent
AI_INTERNALS.mdwas genuinely instructive. The points below are anhonest assessment of what it does and how it maps (or does not map) onto Sanmill's
constraints.
1. A complete, textbook search stack
ai/game_ai.pyimplements essentially the full modern alpha-beta toolkit:negamax + alpha-beta, iterative deepening with time budgets, a Zobrist
transposition table with depth-preferred replacement, two killer moves per ply, a
history heuristic (depth² increments), PVS/NegaScout, Late Move Reductions (last
~60% of moves at depth ≥ 4, with captures and opponent-mill-blocking squares
excluded from reduction), aspiration windows, quiescence search over captures, and
selective search extensions (own mill threat or opponent fork → +1 ply). It also
probes its endgame table and a learned full-game DB from inside the search.
This is a clean, complete implementation and a good reference for what a full
stack looks like. Importantly for Sanmill, though, some of these techniques are
not universally beneficial — see "Lessons already learned" below.
2. A large, knowledge-rich static evaluator
The standout characteristic is
ai/heuristics.py: ~2,784 lines.evaluate()computes on the order of 40 features per leaf (each typically once per side):
closed mills, blocked pieces, two-configs, double-mill pivots, mobility, mill
threats, positional/cross-node value, cycling-mill setups, fork threats,
encirclement/herding, near-blocked "squeeze" pressure, mill-wrapping, fly
asymmetry, open-mill domination, sealed two-configs, assembly gradients (step
1–4), one-config approach, locked-mill penalties, interpose/perp-block,
convergence clusters (with bipartite matching), and more — all phase-weighted
(place/move/fly). On top of that,
tactical_move_bonus()adds ~30 root-only movebonuses, and
_placement_chain_scan()performs a 4-ply forward scan inside theevaluation path.
This is impressive domain knowledge, and in NMM_LLM's context it is a reasonable
choice:
drive the score also serve as explanations ("this move sets up a cycling
mill", surfaced via the bonus breakdown and the LLM layer). A rich evaluator is
partly a narration layer, not only a strength layer.
bought by a leaner evaluator is smaller; investing in per-node knowledge is a
rational trade at that throughput.
It is worth noting transparently that the git history (
B-1…B-64) shows thispath carries a real maintenance cost: a number of commits fix interactions
between heuristics (e.g. capping fly-phase mobility so fly-entry doesn't score
negatively; restoring a convergence term when a mill closes; detecting
"cycling-capture self-unblock"). The authors handled these carefully — the point
is simply that a large evaluator has a large surface area of subtle interactions.
3. An exact 3v3 retrograde endgame table (the most transferable idea)
ai/endgame_solved_db.py+tools/build_endgame_db.pyretrograde-solve all3v3 flying positions into an exact WDL table. The engineering is tidy:
C(24,3) × C(21,3) × 2 = 5,383,840positions, packed at 2 bits/slot → ~1.3 MB total.
bitmask-minimum class), with a final fill pass so runtime lookups need no
canonicalization.
±(INF − depth).This validated, for us, that an exact 3v3 verdict is high-leverage and cleanly
separable. Sanmill can take the idea and go one step further by using the
already-solved Malom database rather than self-solving (details below).
4. Reusable D4 symmetry engineering
ai/board_symmetry.pyprovides canonicalization + inverse-transform helpers(
canonical_board_str,canonical_sequence,SYM_INVERSE) shared across theendgame table, the trajectory DB, and the opening book — a clean "store canonical,
query 8 equivalents, inverse-transform the move back" pattern. This is a good
template for any persisted book/learning data.
5. Learning subsystems and opening variety
TrajectoryDBindexes games by move-prefix and nudges scores by win/loss deltas(with a hard-ban sentinel for user-flagged blunders);
FullGameDBstores learnedoutcomes;
OpeningBook.select_opening()uses UCB1 with temperature-weightedsampling for opening variety. These are primarily UX / human-engagement
features (variety, adaptivity, teaching) rather than raw strength, and they are
nicely done for that purpose.
6. LLM commentary layer
mills_llm.py/coordinator.pyprovide natural-language commentary and anoptional "override" suggestion path. (Sanmill also has LLM-related code; in both
projects the experience depends heavily on the underlying model maturing.)
7. The core architectural trade-off
NMM_LLM sits at the "heavy knowledge, moderate search" design point, which fits
Python + a teaching/UX product. Sanmill sits at the "light evaluation, very fast
deep search" point, which fits a monomorphized Rust engine doing millions of
nodes/sec. For reference, Sanmill's static evaluator is roughly:
value = mobility_diff + 5·(in_hand_diff + on_board_diff + removals_diff)plus amills-piece-count term in the relevant placing variant and a game-over branch
(
crates/tgf-mill/src/rules/evaluation.rs,game_impls.rs).Both are valid; they optimize different objectives under different runtimes. The
key takeaway for Sanmill is not "port NMM_LLM's evaluator", but "borrow the
separable, exact parts (endgame table, symmetry pattern) and keep our evaluator
lean so search stays fast."
What this means for Sanmill
Instead of self-solving, integrate the already-exact Malom database — the C++
reader was previously in this repo on
master(src/perfect/).std_3_3_0_0.sec2, ~616 KB) to prove the fullpipeline (format → index/symmetry mapping → probe → search short-circuit).
those features are (a) redundant with what search already discovers and (b) a
large per-node cost that would reduce nps and likely weaken play. Tactics
belong in search; the evaluator stays lean.
Sector naming (Malom standard):
std_<WB>_<BB>_<WH>_<BH>.sec2→std_3_3_0_0is3-vs-3 on board, none in hand = the flying endgame.
Proposed approach (PerfectDB, single sector first)
master:src/perfect/(
perfect_sector.*,perfect_sec_val.*,perfect_hash.*,perfect_sector_graph.*, plus theperfect_api.*/perfect_adaptor.*glue).For a single-sector probe we only need the subset that decodes a
.sec2sectorand answers a value/WDL query.
and avoid a C++ build dependency; fall back to a thin FFI shim only if
reimplementing the format is impractical.
std_3_3_0_0.sec2as a build asset (or wire an optional-download loadermirroring
master's UX), and exposeprobe(state) -> Option<Wdl>(plus bestcontinuation if available).
return the exact verdict and short-circuit; on a miss, fall through to normal
search.
Important notes & caveats
group; we must map a Sanmill
MillStateinto the DB's canonical index correctlyand must not add a parallel symmetry layer for this feature. This mapping is the
main correctness risk — cover it with tests, using
master:src/perfect/as thereference of truth.
side-to-move / in-hand conventions; the port must translate Sanmill's square
layout,
side_to_move, and on-board/in-hand counts exactly.regenerate the database.
for non-standard variants (diagonal lines, board-full rules, Lasker, etc.).
cheap enough that normal search nps is unaffected.
Out of scope / lessons already learned
regressed throughput — Mill's rules are simple and the bookkeeping cost
outweighed the ordering benefit. Do not reintroduce (even though NMM_LLM uses
them effectively at Python throughput; this is exactly a design-point difference).
"block interconnected double mills"). Interesting, but suspected to hurt nps
given how cheap each node already is. Any such change must be proven by a
self-play / benchmark A/B — which requires the self-play harness to be solid
first. Treated as a gated follow-up, not part of this issue.
until the model side matures. Out of scope here.
Tasks
Phase 0 — Prerequisites
.sec2format and Malom indexing (sourced frommaster:src/perfect/)Phase 1 — PerfectDB reader port
.sec2reader path frommaster:src/perfect/(sector decode + value query)MillState→ Malom canonical index mapping (square layout, side-to-move, in-hand/on-board counts)masterPhase 2 — Embed sector & probe API
std_3_3_0_0.sec2as a build asset (or wire the optional loader)probe(state) -> Option<Wdl>(+ best move if available), with a standard-rules-only variant guardPhase 3 — Search integration
Phase 4 — Validation
cargo run -p tgf-cli -- benchandtests/perf_baseline.tomlPhase 5 — Docs
docs/FRAMEWORK_API.md/ engine docsFollow-ups (separate issues)
Acknowledgments
paper Calculating Ultra-Strong and Extended Solutions for Nine Men's Morris,
Morabaraba, and Lasker Morris.
demonstration of an exact 3v3 retrograde table, clean D4 symmetry engineering,
and a thorough write-up (
AI_INTERNALS.md) that made this comparison easy andenjoyable to do.