diff options
Diffstat (limited to 'src/board_representation')
| -rw-r--r-- | src/board_representation/EVAL.md | 627 |
1 files changed, 627 insertions, 0 deletions
diff --git a/src/board_representation/EVAL.md b/src/board_representation/EVAL.md new file mode 100644 index 0000000..34fb9c3 --- /dev/null +++ b/src/board_representation/EVAL.md @@ -0,0 +1,627 @@ +# Migration plan: bitboard-backed `Eval()` (rewrite, 2026-09-04) + +**Status: planning only, superseding the earlier draft of this +document in full.** That draft was written before the movegen +bitboard work (`MOVEGEN_MIGRATION.md`) landed and assumed a slider +attack-bitboard primitive ("`_RookAttacksBB(c, bbOccupied)`... already +built") that did not actually exist yet at the time. It now does -- +this rewrite is based on having read `_EvalBishop`/`_EvalKnight`/ +`_EvalRook`/`_EvalQueen` in full (not by memory/analogy) and the +landed `generate.c` bitboard infrastructure (real magic tables, not a +sketch) side by side. + +## 0. Why this rewrite exists, and what's different now + +Two things changed since the first draft: + +1. **Real magic bitboards now exist and are directly reusable.** + `_RookAttacksBB(COOR c, BITBOARD bbOccupied)` / `_BishopAttacksBB(...)` + (`generate.c`) are `FORCEINLINE`, non-static, and already used from + three call sites outside their own file's move generation (the + Part B check-block-mask computation, and would be a fourth caller + here). They return the *complete* blocked-ray attack set in one + multiply+shift+double-indirect-load -- not just the nearest + blocker (`_WhoAttacksSquareBB`'s shape, built for a different + question). This is exactly the primitive section 3a of the old + draft assumed; it is no longer aspirational. +2. **The motivating problem is now measured, not assumed.** Profiling + against Crafty on the same hardware (1.5Mnps vs. Crafty's 7.5Mnps, + both at a similar lazy-eval skip rate) points at `Eval()` itself, + not search, as the disproportionate cost. This plan exists to + attack that number directly, not as a speculative "bitboards are + modern, let's use them" exercise. + +Open question: both move gen (generate.c) and eval (eval.c) need a +bbOccupied. Is this worth maintaining incrementally (in +MakeMove/LiftPiece/SlidePiece/etc...) so that it will always be on +POSITION and up-to-date? + +**The movegen project's own findings are the load-bearing precedent +here, and they cut both ways -- worth stating plainly before +proposing more work of the same shape:** + +- Most individual `_Generate*BB` functions landed at **speed parity** + with mailbox (0.71x-1.10x depending on position density), because a + mailbox ray walk's per-square cost was already close to + O(destination count) -- nothing wasted to reclaim. +- The **real, consistent wins** were in a different place: the + dispatch layer (`_GenerateAllMovesBB` avoiding `JumpTable`'s + indirect, badly-predicted call, up to 23% in dense positions) and + `IsAttackedBB` (0.73x-0.93x, a genuine algorithmic improvement, not + just a constant factor, because mailbox `IsAttacked` is O(piece + count) and the bitboard version is closer to O(1)). +- The stashed *first* Eval bitboard attempt regressed 17-41% for a + root cause fully diagnosed at the time: **pure duplication** -- + every mobility function ran its full original ray-walk *and* + additionally wrote bitboards, so every position paid for both + representations and nothing was ever removed to pay for it. Not + evidence against this approach; evidence against ever doing it + additively again. + +Reading `Eval()`'s mobility loops against this precedent suggests +`Eval()` is actually a *better* candidate for a bitboard win than move +generation was, for a structural reason move generation never had: +**every mobility loop today pays for an indirect `switch` dispatch per +square visited** (`RMobCaseTable`/`BMobCaseTable`/`QMobCaseTable`/ +`NMobCaseTable`), explicitly chosen over a function-pointer jump table +"to avoid an indirect call/ret... since the target piece varies square +to square" (`_EvalBishop`'s own comment). A `switch` compiles to a +jump table or branch chain that itself suffers exactly the +misprediction problem `_GenerateAllMovesBB` was built to eliminate +for move generation's dispatch layer -- and it runs *inside* the +hottest loop in eval, once per square walked, not once per node. This +plan's central bet is that removing this dispatch, not the ray-walk +arithmetic itself, is where the win lives -- directly analogous to +what actually paid off in the movegen project, not the part that +didn't. + +## 0b. Performance philosophy for this rewrite -- what's negotiable +and what isn't + +Stated by the user directly, 2026-09-05, and important enough to +record verbatim as a standing constraint rather than let it live only +in chat history: this engine runs at ~1.5Mnps against Crafty's +~7.5Mnps on the same hardware, both profiled, and `Eval()` is the +confirmed disproportionate cost -- not search. That changes the +default posture for every decision in this plan: + +- **Benchmark everything, cut aggressively.** Every existing eval + term is a candidate for removal if it doesn't earn its cost -- + section 7's cut list (dead `#if 0` code, max-mobility-in-a-row + bonus, bishop's transient-pawn credit, connected-rook x-ray + bookkeeping) should be treated as the *starting* set of suspects, + not an exhaustive one, and the DNA-zeroing sensitivity check + + cycle-cost measurement pairing should be run proactively across + `Eval()`'s terms rather than opportunistically on ones that already + look suspicious. +- **The one thing that is not up for negotiation: attack-bitmap-driven, + mobility-and-safety-aware piece evaluation itself** -- not merely + "king safety," a narrower framing worth explicitly correcting here. + What today's `bvAttacks`/case-table machinery gives every piece + type is a single, unified mechanism for "does this piece actually + have useful, safe mobility given the current pawn structure and + attack picture" -- bad bishops, knights needing real outposts (not + just central *squares*, since a centralized knight throttled by an + enemy pawn chain gets no credit), rooks on genuinely open/contested + files, and king danger, all falling out of the same attack-bitmap + substrate rather than being separate hand-tuned heuristics. In the + user's own words: *"If I had to drop everything else from Eval to + afford this, I would still keep it. I believe it is what chess Eval + is about."* Section 2's replacement (`bbPawnAttacks`/ + `bbMinorAttacks`/`bbRookAttacks`/`bbQueenAttacks` accumulators) is + in scope and *encouraged* precisely because it's the fast + replacement for this capability's current slow mechanism -- the + capability survives, only its implementation gets cheaper. What + must not happen is a cut that removes mobility/safety-awareness + itself in the name of speed (e.g. reverting a piece type to + raw material + PST with no attack-picture-conditioned mobility term + at all) -- every other term in `Eval()` is fair game for that kind + of cut; this one specifically is not. +- **Profile before rewriting, not just before/after each toggle.** + Before continuing further piece-type work, get a per-eval-term + cycle-cost breakdown (perf counters or sampling, representative + position mix) of where `Eval()`'s total time actually goes today -- + mobility ray-walks vs. pawn structure vs. king safety vs. everything + else. This should drive both rewrite sequencing and cut-candidacy; + section 4's knight-first ordering was sequenced by implementation + risk (fewest special cases), which is a reasonable tie-breaker but + shouldn't override what the actual profile says is worth attacking + first once that data exists. + +## 0c. `_EvalPawns` -- measured cost, and a concrete redundant-work +finding (2026-09-05) + +The `EVAL_TIME` per-term instrumentation added per section 0b (see +`chess.h`'s `u64CyclesEval*` counters, `root.c`'s breakdown print) +gave a first real number: on one representative middlegame position +(`sd 12`), `_EvalPawns` alone was **5.4% of total cycles spent in +`Eval()`**, despite a measured pawn-hash hit rate above 99% -- i.e. +this cost is overwhelmingly the *hit* path (hash probe + key compare ++ return), not the rare rebuild-on-miss path. Worth remembering for +section 0b's "front-load if cheap, else estimate" framing: pawn +structure is the *first* thing evaluated in `Eval()` today, which is +right for a term this correlated with lazy-eval accuracy, but "first" +doesn't mean "free" -- even a >99%-hit-rate cache probe measurably +adds up at billions of calls, same lesson as the mobility dispatch +switch in section 0. + +**Concrete, scoped finding on the miss path itself** (found by reading +`_EvalPawns`, eval.c:1866 on, against `POSITION`'s own fields): on a +pawn-hash miss, `_EvalPawns` rebuilds `pHash->bbPawnLocations[uColor]` +bit-by-bit inside its per-pawn loop (`pHash->bbPawnLocations[uColor] +|= COOR_TO_BB(c);`, eval.c:1958) by iterating `pos->cPawns[uColor][u]` +-- but `POSITION` already carries `pos->bbPawns[2]` +(`chess.h`:661-670), a plain, always-current, incrementally-maintained +pawn-location bitboard (`move.c` updates it on every pawn move, +`fen.c`/`board.c` build it at position-load time), entirely separate +from the pawn-hash-keyed `pHash->bbPawnLocations[2]`. There's no +reason for the miss path to reconstruct from scratch what's already +sitting on `pos`: `pHash->bbPawnLocations[uColor] = +pos->bbPawns[uColor];` once per color replaces the bit-by-bit OR +inside the loop. **The loop itself still has to run** for the +per-file counts (`pHash->uCountPerFile`) and whatever isolated/ +doubled/duo/passed-pawn detection follows -- this only removes the +bitboard-population part of that loop's work, not the loop -- and +since it's gated behind the <1%-of-calls miss path, the aggregate win +is real but necessarily small. Flagged here as a correctness-safe, +low-risk cut to take regardless of the rest of this plan's sequencing, +not because it's expected to move the 5.4% number much on its own. + +**Explicit scope note, stated by the user directly**: `_EvalPawns` +stays pawn-structure-only -- passed-pawn detection, connectivity, and +whatever else it currently does for pawns are all still wanted. The +ask here is narrower than "simplify pawn eval": stop redoing bitboard +work `POSITION` already maintains for you, don't cut pawn eval terms. + +Every one of `_EvalBishop`/`_EvalKnight`/`_EvalRook`/`_EvalQueen` was +read end to end for this rewrite (`eval.c`). Two clearly separable +halves exist in every one of them: + +### 1a. Non-mobility terms -- already fine, not a target + +Open/half-open file bonuses, rook-behind/leads-passer, knight +outpost/tropism, bishop good/bad-pawn-color counting, centrality, +closed-position scalers -- all of these read `pHash`-cached bitboards +(`bbPasserLocations`, `bbStationaryPawns`, `bbPawnLocations`, +`uCountPerFile`) or O(1) `POSITION` fields. No ray-walk, no per-square +switch, already bitboard-driven where it needs to be. **Do not touch +these** -- rewriting them buys nothing and only adds risk surface. + +### 1b. Mobility ray-walks -- the actual target, and the switch is +the point, not the walk + +All four piece types share the same shape: walk each ray direction +square by square, at every square (1) OR a bit into +`pos->rgSquare[cSquare|8].bvAttacks[uColor]` (see section 2), (2) +dispatch on `pos->rgSquare[cSquare].pPiece` via a per-color 14-entry +case table to decide "count this square? keep walking? x-ray past +it?". Read closely, the case tables collapse to a small, enumerable +set of primitive facts once you stop thinking per-square and start +thinking per-ray-segment: + +**Rook (`RMobCaseTable`, 6 live cases):** +- `RMOB_EMPTY` / `RMOB_ENEMY_LESS` (any enemy piece, actually -- + "less" is a stale name, the table just needs "count if safe, stop + if occupied"): count if `!UNSAFE_FOR_ROOK`, stop if occupied. +- `RMOB_FRIEND_BLOCK` (friendly non-rook/queen): stop, no count. +- `RMOB_FRIEND_ROOK` / `RMOB_FRIEND_QUEEN`: don't stop -- x-ray past a + same-color battery partner, no mobility credit at the blocker + itself, award `ROOK_CONNECTED_HORIZ`/`_VERT` at the blocker square. +- `RMOB_ENEMY_SAME` / `RMOB_ENEMY_GREATER`: count (unconditionally, + no unsafe-check -- this is deliberate, capturing an equal/higher + rook/queen/king is never "unsafe" in the sense mobility cares + about), stop. + +Reduction: `bbAttack = _RookAttacksBB(c, bbOccupied)` already stops at +the nearest blocker in every direction by construction -- that +blocker is exactly the one square each case above is examining. +`uTotalMobility = CountBits((bbAttack & ~bbFriendlyOccupied & +~bbUnsafeForRook) | bbEnemyBlockerCredit)`, where `bbEnemyBlockerCredit` +handles the fact that an occupied-by-enemy terminal square counts +*without* the unsafe check (`RMOB_ENEMY_SAME`/`_GREATER` never test +`UNSAFE_FOR_ROOK`, only `RMOB_EMPTY`/`_ENEMY_LESS` -- i.e. the +non-terminal-and-terminal-enemy cases both existed in the original +table, but only the *empty-square* case is masked by +`bbUnsafeForRook`; every enemy-occupied terminal square counts +unconditionally regardless of case name). Concretely: + +```c +bbAttack = _RookAttacksBB(c, bbOccupied); +bbEnemy = bbAttack & bbEnemyOccupied; /* terminal enemy squares */ +bbEmpty = bbAttack & ~bbOccupied; /* non-terminal empty squares */ +bbMobility = bbEnemy | (bbEmpty & ~bbUnsafeForRook); +uTotalMobility = CountBits(bbMobility); +``` + +The friendly-rook/queen x-ray case needs its own handling since a +magic lookup's attack set stops *at* the blocker regardless of type, +giving no visibility past it: detect `bbFriendRQ = bbAttack & +(pos->bbPieces[uColor][ROOK] | pos->bbPieces[uColor][QUEEN])` +(0 or 1 bits, essentially always), and only when nonzero, recompute +`_RookAttacksBB(c, bbOccupied & ~bbFriendRQ)` restricted to the same +ray (`g_RookRayToEdge[dir][c]`) to get the far-side squares for the +attack-bit population in section 2 -- this does not change +`uTotalMobility` (the original code never credited mobility past a +friendly battery partner either). The connected-rook bonus itself +(`ROOK_CONNECTED_HORIZ`/`_VERT`) needs to know if the ray to the +blocker was horizontal or vertical, which is already known for free +from *which* of the 4 ray directions found it. + +**Per-direction max mobility**: `CountBits(bbMobility & +g_RookRayToEdge[dir][c])` for each of 4 directions -- 4 extra popcounts, +still branch-free, no walk. + +**Bishop (`BMobCaseTable`, 7 live cases) -- one genuine wrinkle rook +doesn't have:** + +- `BMOB_EMPTY` / `BMOB_ENEMY_PAWN`: same shape as rook's + empty/enemy-less, masked by `UNSAFE_FOR_MINOR`. +- `BMOB_FRIEND_BLOCK`: stop, no count (friendly knight/rook/king). +- `BMOB_FRIEND_PAWN`: stop, **but still counts as 1 mobility square + if `pos->bb & COOR_TO_BB(cSquare)`** -- `pos->bb` here is a + *scratch* alias set to "pawns of either color, non-stationary, + restricted to this bishop's own color complex" just before the walk + begins (`pos->bb = bbPc`, see the code right above the ray loop). + This is real, intentional signal (a friendly *transient* pawn -- not + rammed/backward -- sitting on the bishop's own diagonal still + "counts" toward mobility, on the theory it'll likely move and open + the diagonal soon) and is **the one case that isn't a pure + `~bbFriendlyOccupied` mask** -- it needs `bbAttack`'s terminal + friendly-pawn bit added back in when that pawn is transient and on + the bishop's own color. Since at most one square per ray direction + can be this case (the walk stops there), this is a cheap + `bbAttack & pos->bbPawns[uColor] & bbTransientOwnColorPawns` term + ORed into the mobility mask, not a structural problem -- just a + named exception that must not be dropped silently during the + rewrite. +- `BMOB_ENEMY_SAME` (opposing bishop/knight): count, stop. +- `BMOB_FRIEND_XRAY` (friendly bishop/queen): x-ray past, no count, + same shape as rook's battery case. +- `BMOB_ENEMY_GREATER` (enemy rook/queen/king): **count, then x-ray + past anyway** -- unlike rook, bishop's x-ray set includes both the + same-type friendly case *and* an enemy-major-piece case. This means + the "recompute with blocker excluded" trick (needed for attack-bit + population past the blocker, section 2) has to run for *two* + distinct terminal-piece categories here, not one. + +**Queen (`QMobCaseTable`, 7 live cases) -- mechanically rook+bishop, +with one extra wrinkle from combining two ray families:** + +- Same empty/enemy-less/friend-block/enemy-same shape as rook, masked + by `UNSAFE_FOR_QUEEN`. +- `QMOB_FRIEND_BISHOP` x-rays only on a diagonal ray, blocks on an + orthogonal one (`fStop = fOrthogonalRay`); `QMOB_FRIEND_ROOK` is the + mirror (`fStop = !fOrthogonalRay`). This is already exactly what + computing queen mobility as two separate rook-direction/ + bishop-direction magic lookups naturally gives for free -- no new + logic needed, `fOrthogonalRay`'s role is entirely subsumed by "which + of the two lookups this ray direction belongs to." +- `QMOB_FRIEND_QUEEN`: x-ray past (either ray family), same as rook's + own-type case. +- `QMOB_ENEMY_GE` (any enemy piece): count, stop, **no x-ray past** -- + unlike bishop's `BMOB_ENEMY_GREATER`, queen does not x-ray through a + captured-but-higher-value enemy. One fewer special case than bishop, + not more. + +Reduction: `bbAttack = _RookAttacksBB(c, occ) | _BishopAttacksBB(c, +occ)`, each masked/unsafe-checked with its own family's mask +(`bbUnsafeForQueen` uniformly, since the mailbox code doesn't +distinguish rook-direction vs. bishop-direction safety for the +queen), matching `MOVEGEN_MIGRATION.md` section 3's own already-tested +"combined 8-ray table measured slower than reusing the rook/bishop +tables in two passes" finding -- reuse that two-pass structure here +too, don't rediscover the same regression. + +**Knight (`NMobCaseTable`, 3 live cases) -- the simple case, confirmed +by direct read:** + +- `NMOB_MOBILE_SQUARE` (empty or enemy pawn): count if + `!UNSAFE_FOR_MINOR`. +- `NMOB_ENEMY_OTHER` (any other enemy piece): count, unconditionally. +- `NMOB_FRIEND`: no count. + +No terminal-blocker subtlety at all (knights don't block through +anything). Reduction is exactly one line: + +```c +bbAttack = g_KnightAttacksBB[c]; +bbMobility = (bbAttack & bbEnemyNonPawnOccupied) | + ((bbAttack & ~bbFriendlyOccupied & ~bbEnemyNonPawnOccupied) + & ~bbUnsafeForMinor); +uMobilitySquares = CountBits(bbMobility); +``` + +(the split exists only because `NMOB_ENEMY_OTHER` skips the unsafe +check and `NMOB_MOBILE_SQUARE` doesn't -- can likely simplify further +once `bbEnemyNonPawnOccupied`'s exact membership is nailed down +against the case table above). + +### 1c. What every one of these functions does *after* mobility -- +unaffected + +Trapped-piece recording (`_RecordTrappedCandidate`, gated on +`uTotalMobility == 0`/`< 3`) and the rook-corner king-trap check are +already independent of *how* the mobility count was computed -- +feed them the bitboard-computed number, no rewrite needed. + +## 2. `bvAttacks` replacement -- still the load-bearing new +infrastructure, design unchanged from the first draft, now +concretely groundable against real code + +`ATTACK_BITV` (`chess.h`) packs presence bits per piece-type family +into one `ULONG` per square per color half +(`pos->rgSquare[c|8].bvAttacks[color]`), with `UNSAFE_FOR_MINOR`/ +`_ROOK`/`_QUEEN` macros masking the relevant bits +(`0x80`/`0xC0`/`0xE0` -- i.e. each wider piece type's "am I safe here" +question is a superset of the narrower one's, matching chess reality: +a queen cares about more attackers than a rook, which cares about more +than a minor). This is read constantly (every mobility loop's +`UNSAFE_FOR_X` check) and written constantly (every mobility loop's +`|= uBit`), in a strict piece-evaluation order that later pieces +depend on. + +Replacement, same shape as the first draft proposed, now stated +precisely against the actual bit semantics above: + +- `Eval()`-local (not `POSITION`-resident -- same call-scoped-lifetime + discipline `MOVEGEN_MIGRATION.md` section 3c/6b already established + for its own scratch bitboards) per-side accumulators: + `bbPawnAttacks[2]`, `bbMinorAttacks[2]` (knight | bishop, matching + `UNSAFE_FOR_MINOR`'s own single-tier mask), `bbRookAttacks[2]`, + `bbQueenAttacks[2]`. +- Populated in `Eval()`'s existing piece-evaluation order (pawns, + then knights/bishops, then rooks, then queens, then king), each + piece type OR-ing its own `bbAttack` (the same value computed for + its own mobility, no second computation) into its side's + accumulator once, immediately after that piece's own mobility is + scored. +- `bbUnsafeForMinor` at knight/bishop-evaluation time = + `bbPawnAttacks[enemy]` (matches the `0x80` mask -- only pawns make a + square unsafe for a minor). `bbUnsafeForRook` = `bbPawnAttacks[enemy] + | bbMinorAttacks[enemy]` (`0xC0`). `bbUnsafeForQueen` adds + `bbRookAttacks[enemy]` (`0xE0`). Each is a single OR of + already-populated accumulators, computed once per side per `Eval()` + call -- not once per candidate square, a strict improvement over + today's per-square `ATTACK_BITV` read. +- **King safety zone queries** (`_EvalKing`'s read side, section 3 + below) become `CountBits(bbXAttacks[enemy] & kingZoneMask)` directly + against these same accumulators -- no separate data structure + needed for that consumer. + +One correction versus the first draft's framing: `pos->rgSquare[]` +itself is **not fully retirable** even after this lands, for a reason +the movegen project's own `movesup.c` survey already surfaced -- +every generator, bitboard or not, still needs `pos->rgSquare[c].pPiece` +to answer "what's on the terminal/blocker square" (for the +enemy-vs-friendly, same-vs-greater-value classification every piece +type's reduction above still needs at the *one* terminal square per +ray, not for the whole ray). This plan retires the mailbox *ray-walk +and the bvAttacks per-square bitfield*, not the mailbox array itself. + +## 3. `CountKingSafetyDefects`/`_EvalKing` -- unchanged scope from the +first draft, still explicitly separate + +`_EvalKing`'s *read* side (walking `KingSafetyDeltas` around the +king, summing attacker/defender presence) becomes a `CountBits` +query against section 2's accumulators, a pure consumer change. +`CountKingSafetyDefects` itself is **not** a port target -- per the +first draft's already-recorded decision, it gets a from-scratch +bitboard-native reimplementation that need not preserve exact +semantics (allowed to be *more accurate*, since it's a lazy-eval +magnitude hint, not a scored term itself), scoped and gated entirely +separately from the mobility rewrite below. Do not bundle it into the +same toggle or the same benchmark pass. + +**Correction, found by reading the actual call sites (not by +analogy): `CountKingSafetyDefects` cannot consume section 2's +accumulators even opportunistically, and its role is bigger than "a +lazy-eval magnitude hint."** + +1. **Ordering makes reuse structurally impossible, not just + undesirable.** `CountKingSafetyDefects` runs during `Eval()`'s + lazy-eval margin phase (`EstimatePositionalScore`, and the + `CountKingSafetyDefects(pos, WHITE/BLACK)` refresh calls at + eval.c:4976-4977) -- strictly *before* the piece-eval loop that + builds `bvAttacks` today, and before section 2's `bbPawnAttacks`/ + `bbMinorAttacks`/`bbRookAttacks`/`bbQueenAttacks` accumulators + would exist under this plan (pawns -> knights/bishops -> rooks -> + queens -> king). There is no rewrite of `CountKingSafetyDefects` + that can read those accumulators; by construction they aren't + populated yet at the point it's called. It has to remain what it + already is: a geometry-only estimate over enemy piece *locations* + relative to the king (`CHECK_VECTOR_WITH_INDEX`), with no attack + data available, bitboard-native or otherwise. +2. **It is not only a lazy-eval input.** `search.c:1252` + (`CountKingSafetyDefects(pos, pos->uToMove) > 2`) and + `searchsup.c:484` (`CountKingSafetyDefects(&ctx->sPosition, uColor) + > 1`) call it directly as a search-time extension/reduction gate, + fully independent of `Eval()`'s lazy-exit path. A drift here + doesn't just blur a margin estimate -- it changes which nodes get + extended or reduced at every level of the tree. + +**The actual constraint this plan needs, stated precisely**: not +"port `CountKingSafetyDefects` to bitboards," but *"whichever accuracy +improvements section 3's `_EvalKing` rewrite introduces (e.g. properly +catching x-ray/latent queen threats via the new accumulators), +`CountKingSafetyDefects`'s cheap geometric estimate must remain +correlated with `_EvalKing`'s real danger score across the position +space."* Concretely, the two thresholds already load-bearing in +production (`> 1` in `searchsup.c`, `> 2` in `search.c`) need to keep +firing on roughly the same set of positions `_EvalKing`'s new, +possibly-more-accurate score would flag as dangerous. If `_EvalKing` +gets meaningfully better while `CountKingSafetyDefects` stays exactly +as it is today, that correlation can silently degrade even though +neither function, read in isolation, has a bug -- the failure mode is +between them, not inside either one. + +**Added verification step (section 5 should include this, not just +`_EvalKing`'s own exact-score harness)**: over the same 20,000-position +`GenerateRandomLegalPosition` sample, compute both +`CountKingSafetyDefects(side)` (old, unchanged) and the new +`_EvalKing`'s real danger score, old vs. new, and check: + +- **Correlation, not equality** -- these were never meant to match + exactly (one is a cheap O(pieces) geometric proxy, the other a full + bitboard-weighted computation); the thing to track is whether their + relative ranking of "how dangerous is this king position" stays + consistent before vs. after the `_EvalKing` rewrite. +- **Threshold agreement specifically at the two live gates** -- for + each sampled position, does `CountKingSafetyDefects(side) > 1` (and + `> 2`) still agree with "old `_EvalKing` considered this position + dangerous" at roughly the same rate it agrees with "new `_EvalKing` + considers this position dangerous"? A meaningful shift in agreement + rate, not just a raw score delta, is the signal that + `CountKingSafetyDefects` itself may need re-tuning (its + `KingFlightDefects`/`KingStormingPawnDefects`/`CHECK_VECTOR` + constants are DNA-tunable, per `eval_tune/`) to stay a useful proxy + for the new, more accurate ground truth -- not evidence that the + `_EvalKing` rewrite itself is wrong. +- **Downstream check**: re-run `match_play.py` and the curated-suite + `sd10` gate (section 5.2/5.3) with `_EvalKing`'s rewrite landed but + *before* touching `CountKingSafetyDefects`'s constants, specifically + watching for search-behavior regressions (unexpected extension/ + reduction pattern shifts) that a pure eval-score comparison would + miss, since sections 5.1-5.3 as currently scoped only compare + `Eval()`'s output, not `CountKingSafetyDefects`'s search-time + pruning effect. + +## 4. Toggle strategy -- learn from the stash's mistake explicitly + +One `#define` per piece type (`EVAL_KNIGHT_BITBOARD`/ +`EVAL_BISHOP_BITBOARD`/`EVAL_ROOK_BITBOARD`/`EVAL_QUEEN_BITBOARD`), +matching the movegen project's per-piece-type convention -- **but each +toggle must replace that piece type's entire mobility+attack-bit +computation, never add a bitboard computation alongside the mailbox +one.** This is not a style preference; it is the single, fully +diagnosed cause of the stashed attempt's 17-41% regression, and must +be treated as a hard constraint on every toggle in this plan, not a +lesson to merely keep in mind. Section 2's `bbUnsafeFor*` +infrastructure is shared prerequisite state, needed before *any* +piece type's toggle can be flipped on -- there's no meaningful +partial-rollout ordering where accumulator infrastructure lands after +a consumer. + +Suggested order, cheapest/most-precedented first (matches +`MOVEGEN_MIGRATION.md`'s own successful sequencing rationale): +knight (no terminal-blocker special case at all) -> rook (one x-ray +category) -> bishop (two x-ray categories, one non-mask-reducible +transient-pawn credit) -> queen (mechanical once rook+bishop are +proven, reuses their two-pass structure). + +## 5. Correctness verification + +Same bar as `MOVEGEN_MIGRATION.md`, arguably higher since `Eval()` +feeds every leaf score in the tree rather than just node +ordering/generation: + +1. **Exact-score comparison harness**: for each of + `GenerateRandomLegalPosition`'s 20,000-position sample (same source + the movegen project's own harness used), compute the specific + `EVAL_TERM` contributions this plan touches (mobility + + connected/x-ray bonuses + trapped-candidate flag) old vs. new, + per piece type independently (mirrors the movegen project's + per-piece-type toggle granularity in the harness too). Must be + byte-identical -- `Eval()` is deterministic-by-design, any mismatch + is a real bug. +2. **Whole-engine `sd10` on all three curated suites vs. + `head_reference`** -- rebuilt post-movegen-landing baseline, so + this plan's own gate isn't contaminated by the movegen commit's + own (already-confirmed-unrelated) `ecm_hard_quick` drift. +3. **`match_play.py`** (`LOWER95 >= 0.5`), same gate, same reasoning: + given the movegen project's own experience that most individual + functions land near parity, expect this gate to converge toward + "no regression" rather than a large positive swing, and don't + over-read a near-0.5 LLR as a problem the way the movegen SPRT run + didn't need to either. +4. **`precommit_check.sh`** as always, for the crash/assert layer. + +## 6. Microbenchmarking + +Same two-tier approach as every prior plan in this family: + +1. **Isolated cycles/call per piece type**, interleaved old/new, + across opening/middlegame/endgame density, modeled on + `testgenerate.c`'s existing methodology (new `testeval.c`-resident + harness, or extend the existing one). Given section 0's + dispatch-elimination bet, pay particular attention to whether the + win (if any) tracks with *how many pieces/squares* a position has + to walk (more squares -> more switch mispredictions saved) rather + than being flat -- a density-correlated win would confirm the + dispatch-removal theory; a flat or absent one would suggest the + `switch` was actually being predicted fine and the bet was wrong. +2. **Whole-`Eval()` cycles/call**, same interleaved technique -- the + number that actually matters, since mobility is only part of + `Eval()`'s total cost. +3. **Whole-engine NPS** on the curated suites, and directly against + the original motivating comparison: **re-run the same Crafty + side-by-side NPS comparison that motivated this plan**, not just + an internal before/after -- the actual goal is closing (some of) + the 1.5M-vs-7.5Mnps gap, and that number is the one that should + ultimately justify the effort here, not an isolated microbenchmark + in a vacuum. + +## 7. What to consider cutting, not just speeding up + +Raised at the user's request -- anything that resists a clean bitboard +reduction is a candidate for this discussion, not an automatic +"implement it the hard way": + +- **Dead code, delete regardless of this plan**: every `#if 0` + min-mobility-tracking block in `_EvalRook`/`_EvalBishop`/ + `_EvalQueen`/`_EvalKnight` -- unused (`pos->uMinMobility` is written, + never read live), pure removal, zero risk, zero dependency on + anything else in this plan. +- **Per-direction max-mobility bonus** + (`ROOK_MAX_MOBILITY_IN_A_ROW_BONUS`/`BISHOP_MAX_MOBILITY_IN_A_ROW_BONUS`) -- + survives the rewrite as 4 extra `CountBits` calls per slider, cheap + either way, but worth a DNA-tuning-style sensitivity check (does + zeroing this table's contribution move solve rate or match score at + all?) independent of the bitboard work -- if it's not pulling + weight, cutting it removes both eval-time cost and tuning-parameter + count for free. Not blocking this plan; worth doing opportunistically + once the rewrite is in place and the harness exists to check it + cheaply. +- **Bishop's `BMOB_FRIEND_PAWN` transient-pawn mobility credit** + (section 1b) -- the single case that doesn't reduce to a mask + operation as cleanly as everything else. It's cheap to keep (one + extra AND term), but if the isolated-benchmark step shows bishop's + reduction costing more than knight/rook/queen's for a + disproportionately small eval-quality contribution, this specific + case is the one to question first, precisely because it's the one + genuinely bespoke piece of logic in the whole rewrite. Measure + before deciding, per section 6 -- don't cut preemptively. +- **`CountKingSafetyDefects`** -- not a "cut" candidate, but flagged + again here because it's the one function in this whole area that + isn't a straightforward port either way (section 3); if the + from-scratch reimplementation turns out to be disproportionately + expensive to get right relative to its actual scoring impact, that's + a legitimate place to ask "is a simpler, less-accurate hint good + enough" rather than chasing full replacement fidelity. +- **Connected-rook / bishop-x-ray attack-bit population past a + blocker** (section 1b's "recompute occupancy with blocker excluded" + trick) -- this exists purely to keep populating `bvAttacks`-derived + king-safety-relevant bits correctly for squares beyond a battery + partner. If, once section 2's accumulators are live and + `_EvalKing`'s consumption is measured, this far-side attack + information turns out to contribute negligibly to king-safety + scoring in practice (worth checking via the same DNA-sensitivity + technique), it's a legitimate cut -- dropping it would remove the + single most annoying piece of bookkeeping in this entire plan (the + two-terminal-category case for bishop, section 1b) for what might be + a very small eval-quality cost. Explicitly flagged as the highest- + complexity-per-value item in the plan; measure before committing to + full fidelity. + +## 8. Retirement criteria + +Same shape as `MOVEGEN_MIGRATION.md` section 7: per piece type, delete +that type's mailbox mobility ray-walk only after the exact-score +harness (section 5.1), the `sd10` curated-suite check (section 5.2), +the isolated-cycles benchmark (section 6.1) showing a consistent win +(or an explicit decision to keep it at parity for the dispatch-layer +win, same reasoning the movegen project used for its own near-parity +functions), and `match_play.py` (section 5.3) all pass. Don't let +knight's likely-clean bill of health lower the bar for bishop's +two-x-ray-category case or queen's combined-ray-family case -- +each has different enough special-case surface (section 1b) to +warrant its own full pass through this list. |
