1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
|
# Replacing the loopy mobility chain-walks with single-probe magic bitboards
## Motivation
`_EvalBishop`/`_EvalRook`/`_EvalQueen` currently compute mobility (and the
king-safety x-ray bitboards) via an unbounded `for (;;)` chain walk that
repeatedly recomputes `_RookAttacksBB`/`_BishopAttacksBB` with newly-
discovered blockers excluded, to handle batteries (friendly R/Q stacked
behind each other) and x-ray-through-high-value-enemy-pieces. Crafty does
this with one magic-bitboard probe and a popcount. Goal: match that for
speed while keeping the signal that actually matters for eval quality.
Approximate parity, not exact parity -- see the tradeoffs below, all
explicitly agreed rather than accidental.
## What's being thrown away, deliberately
1. **Battery mobility/connectivity through more than one hop.** Today, a
rook stacked three deep behind two friendly rooks on an open file gets
mobility and connected-rook credit for every square and every blocker
in the stack. After this change, each rook only sees its own first
blocker. The squares/credit "lost" at the near rook are not actually
lost from eval overall -- the rook(s) further out credit those same
squares on their own pass. Confirmed via the king-safety-xray analysis:
the only case where continuing past a friendly blocker added
information nothing else already had was the enemy-GE case below.
2. **Mobility credit for squares beyond an enemy queen/king (or, for
bishop, enemy rook) that the piece is directly attacking.** This was
never "real mobility" in the legal-move sense anyway -- a rook cannot
physically move past a piece it would capture in one ply. Dropping it
is a correctness fix that happens to fall out of the simplification,
not a loss. (Confirmed, no objection -- see open question 6, closed.)
3. **The general multi-layer x-ray bitboard** (`bbMinorXrayAttacks`/
`bbRookXrayAttacks`/`bbQueenXrayAttacks`), replaced by a narrower,
cheaper mechanism (below) that preserves the one case that matters:
rook/bishop/queen pointed at the enemy king (or its ring) through
exactly one enemy queen/king (or, for bishop, rook) blocker.
## What's being preserved
- The existing "real mobility" masks (`bbUnsafeForRook`, `bbUnsafeForMinor`,
`bbUnsafeForQueen` -- squares where a cheaper enemy piece would recapture
for free) are untouched. These already encode "real mobility" exactly as
wanted; nothing new needed here.
- The deferred low-mobility re-derivation in `_EvalTrappedPieces` is
untouched -- it only consumes a `bbMobility` snapshot and
`pos->bbKingAttacks[xColor]`, neither of which cares how the snapshot
was computed.
- The king-ring x-ray signal for `_EvalKingSafety` (the skewer/pin-adjacent
"rook/bishop/queen threatens through the enemy queen/king" case) is
preserved, just computed more cheaply (below).
- Blocking semantics for the x-ray check exactly match what the chain walk
does today: any pawn or minor piece fully blocks (no special-casing of
rammed vs. transient pawns -- that would have been *new* behavior, not
preserved behavior, and we're explicitly not adding it). Friendly R/Q and
the piece's enemy-GE set (queen/king for rook and queen; rook/queen/king
for bishop) are the only things that don't block.
## Per-piece-type design
### Bishop / Rook (mobility)
```
bbAttack = _{Bishop,Rook}AttacksBB(c, pos->bbOccupied);
bbMobility = (bbAttack & ~bbOccupied & ~bbUnsafe)
| (bbAttack & bbEnemySame)
| (bbAttack & bbEnemyGE)
| (bbAttack & bbEnemyLE & ~bbUnsafe); // bishop's transient-pawn case folds in here
uTotalMobility = FastCountBits(bbMobility);
pos->bb{Minor,Rook}Attacks[uColor] |= bbAttack;
```
One magic probe, no loop. Same masks as today, just applied to the single
first-blocker attack set instead of a chain-extended one.
### Queen (mobility)
Two probes (rook-direction + bishop-direction), same mask logic, no
per-probe chain:
```
bbAttack = _RookAttacksBB(c, occ) | _BishopAttacksBB(c, occ);
bbMobility = (bbAttack & ~occ & ~bbUnsafeForQueen)
| (bbAttack & bbEnemyGE)
| (bbAttack & bbEnemyLE & ~bbUnsafeForQueen);
pos->bbQueenAttacks[uColor] |= bbAttack;
```
### Connected-rook bonus
Direct-attack-only, no chain:
```
bbConnected = bbAttack & pos->bbPieces[uColor][ROOK];
// fire EVAL_TERM per bit, horiz/vert exactly as today (RANK(c)==RANK(cBlocker))
```
### King-ring x-ray (replaces bbXXXXrayAttacks' chain-walk population)
Computed inside each piece's own eval pass (not moved into
`_EvalKingSafety` -- that function's consumer code at eval.c:5216-5248 is
unchanged), gated so the common case (no enemy GE piece directly attacked)
costs nothing beyond an AND against a bitboard already in hand:
```
bbSeenEnemyGE = bbAttack & bbEnemyGE; // already computed above for mobility -- free
bbXray = 0;
while (bbSeenEnemyGE) {
sq = pop a bit;
bbXray |= _{Bishop,Rook}AttacksBB(c, occ & ~BBSQUARE[sq]);
}
pos->bb{Minor,Rook,Queen}XrayAttacks[uColor] |= bbXray;
```
- Rook: `bbEnemyGE` = enemy queen | enemy king -- at most 2 extra probes,
almost always 0.
- Bishop: `bbEnemyGE` = enemy rook | queen | king -- at most 3 extra probes,
almost always 0.
- Queen: same as rook, applied per axis.
- One probe per GE piece directly attacked is sufficient -- if removing
that single blocker reveals the king/ring, we're done; we never need a
second layer, since the only thing consumed downstream is ring-square
membership, not an arbitrarily deep battery.
- No `g_VectorDelta`/ring-square enumeration needed anywhere -- the gate is
"was this enemy GE square already in my direct attack bitboard," which is
free, not "check every ring square against every enemy piece," which
would have been the expensive shape.
## Order / plumbing
No change to eval call order (pawns -> knights/bishops -> rooks -> queens
-> king, per `Eval()`'s existing sequence) and no change to where results
land on `POSITION` (`bbMinorAttacks`/`bbRookAttacks`/`bbQueenAttacks`/
`bb{Minor,Rook,Queen}XrayAttacks`, all `[2]`-indexed by color, already
there). Knight needs no change -- it was never loopy to begin with
(single-hop `g_KnightAttacksBB` + mask).
## Validation plan
- `gmake TEST=1` + `debug_smoke_test.sh` first (asserts on mobility bounds
like `ASSERT(uTotalMobility <= 14)` still need to hold).
- Then the three curated suites (`ecm_ringers`/`ecm_confident_quick`/
`ecm_hard_quick`) at fixed `sd`, compared against `head_reference/` --
expect small, explainable deltas (battery/overcount cases), not a cliff.
If solve counts move more than noise-level, suspect the x-ray gating
logic (an enemy GE piece not being re-detected) before suspecting the
mobility masks (those are a straight port).
- `eval_tune/`'s DNA re-tune is not required before landing this (mobility
*tables* themselves are unchanged, only how the bitboard feeding them is
computed), but worth a sanity check with `evaldna dump` before/after on
a few batteries/pin positions by hand if anything looks off.
|