Skip to content

feat(eval): Count the enemy pieces bearing on the king's ring, at zero weight - #258

Merged
aywrite merged 1 commit into
masterfrom
eval/king-attack
Sep 15, 2026
Merged

aywrite merged 1 commit into
masterfrom
eval/king-attack

Conversation

@aywrite

@aywrite aywrite commented Sep 15, 2026

Copy link
Copy Markdown
Owner

The rest of king safety, first half: the count. For each side, four counts of how many squares of the enemy king's ring (the eight squares a king attacks from its square) its knights, bishops, rooks and queens attack, over the real occupancy. A slider stops at the first piece of either colour and counts that square if it is in the ring. A square two pieces attack counts twice. Pawns and kings carry no column: the storm already reads the pawns, and a king bearing on the other king's ring is the opposition. Folded white less black against four packed weight pairs through the existing taper, the way mobility folds.

All four weights are zero in this commit, so the bench is 3868987 and every pin is unchanged. The count is not folded away by the compiler at zero weight: measured, it cost 3.2% (mean) to 4.0% (best) of nps against a build with the summand removed, over an identical tree. So the summand sits behind a SCORED const derived from the weights, mobility's pattern, and the fit commit turns it on with no edit. speed.sh reads -0.1% median, +0.2% fastest against master over 21 interleaved rounds at a 5.6% spread.

The term is uncached. It reads every piece of one side, so its key is the position, and the mobility cost record measured that cache at 1.59% fewer instructions and about 1% slower on the clock.

Fourteen hand counts pin the definition (one per kind, a friendly and an enemy blocker, two attackers of different kinds and two of one kind, an attacker inside the ring, a pinned attacker, a corner king, a centre king on a defended square, a pawn and a king that must not count, two bare kings), plus the ring's size on every square, a mirrored reading, a fold test, and two tuner tests. The tuner lays the four columns out from the TERMS table, prints them in the layout line, and scripts/tune.py prices them at 2, 3, 4 and 6 with a hold flag.

Release tests 607 to 614, python 215 to 218. The fit, on a fresh sealed group, is the next commit.

🤖 Generated with Claude Code

…o weight

A fourth leaf term, in a file of its own beside the other three. For each
side, how many squares of the other king's ring its knights, its bishops, its
rooks and its queens attack, one count per kind and summed over the pieces of
that kind. The ring is the eight squares a king attacks from where it stands,
which is five on an edge and three in a corner. The square the king stands on
is not in it: no quiet position carries an attack on that square, so no fit
could ever price a column holding one.

The attack set is the one mobility already walks, over the real occupancy,
with nothing taken out of it. That is where the two terms part company.
Mobility drops this side's own squares and the squares an enemy pawn covers,
because a square a piece cannot go to is not scope. An attack is the other
question, and a rook bears on the pawn in front of the king whether or not a
bishop guards it. So a slider stops at the first piece of either colour,
counts that square if the ring holds it, and sees nothing past it. A ring
square two pieces attack is counted twice, once per attacker, since how many
pieces bear on the king is what the count is after. Pawns and kings carry no
column of their own: the storm already reads the enemy pawns on the three
ranks in front of the king, and a king bearing on the other king's ring is
the opposition.

All eight weights are zero. They are what the fit moves, and the counts are
here now so that a corpus extracted from this build carries their
coefficients whatever the weights beside them say. The alternative, a nominal
weight to make the term visible, would move the tree, and the claim of this
commit is that it does not.

At zero weight the leaf does not take the counts at all. `SCORED` is read off
the weights at compile time, the way mobility's `SCORED_KINDS` is, and the
summand in `sum` stands behind it, so nothing in the evaluation walks the
pieces while every weight is zero. The fit turns the term on by replacing the
weights, with no line in the sum to edit.

That skip is not the compiler's doing, which is worth saying because the
first draft of this commit said it was. Multiplying a count by zero leaves
the walk that produced it standing and llvm keeps it. Built with the summand
unconditional, the same 3,868,987 node tree ran 3.2% fewer nodes per second
on the mean of 21 interleaved rounds and 4.0% fewer on the best of them.
Behind the constant the tree runs at the speed of the commit below this, on
the same measurement: -0.1% on the median of 21 interleaved rounds and +0.2%
on the fastest of them, against a spread of 5.6%. Both are well inside it, so
what the run says is that nothing was added to the leaf and not that something
was taken off it.

The term is not cached. What it reads is the whole occupancy and one side's
pieces, which is the position, so the only key it could sit behind is the one
the mobility cache was measured on. That measurement: 29.21% of evaluation
calls repeat inside a search and 18.43% of them hit a direct mapped table of
16,384 slots, the table that was built took 1.59% off the instructions and
put about 1% on the clock, and it was rejected on the memory traffic. A
second table under the same key pays that latency again for a fifth of the
work. The shelter's cache earns its place because pawns and kings move on a
minority of plies, and nothing about this term has that property.

Two tests had to move. `every_term_writes_the_counts_its_width_claims`
asserted that every term is worth something at one end of the taper, which a
term shipping at zero is not. It now asserts which term that is, so the fit
fails there until the line goes with it.
`the_table_names_the_terms_the_sum_adds` reads this term's fold against
weights of its own, since the shipped fold is nothing wherever it is read.
That test holds the table and the sum together for a priced term. An unpriced
one stands outside it until the fit, which the test now says.

`scripts/tune.py` prices the four columns at 2, 3, 4 and 6. Two and four are
exact: at most two of a knight's eight targets fall in the three by three
block, and a rook on d3 against a king on e4 reaches four of the ring. A
bishop reaches two, since a diagonal through the block runs into the king's
square and stops there, and the best queen squares reach five, so those two
columns are given a square in hand. `--hold-king-attack` is new, so that a
later refit of a term below this one moves that term alone.

The counts are pinned by fifteen hand counts, the size of the ring on every
square, a mirrored reading for black, a fold against trial weights, and three
rows in the tuner's own tests. Three of the hand counts are what a mutation
of the walk gets caught by and nothing else: two rooks reading one rank,
which a union per kind would answer three to, and a pawn and a king standing
against the ring, which carry no column. `SCORED` has a test of its own that
it is false exactly when every weight is zero at both ends of the taper. At
zero weight the reconstruction identity sees nothing of this term, so those
are the whole of what guards it.

The bench is identical by construction, and that is the claim. A term worth
nothing moves no score and so moves no tree, and at zero weight it is not
counted either. The eighteen default pins, the eighteen reference ones, the
tactical pass count and the strategic point total all stand where they were.

Bench: 3868987
Elo: not measured
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@aywrite

aywrite commented Sep 15, 2026

Copy link
Copy Markdown
Owner Author

Review round 1, folded into 7cdceae.

A second agent rebuilt both sides in its own build directory: 3868987 on both, all eighteen bench rows identical in every column but time, 607 to 613 release tests at the time, clippy and fmt clean. It re-derived all fourteen hand counts from the definition before reading the commit's numbers and every one agreed. It ran nine mutations of the count against the suite.

Findings, one behavioural on the clock and the rest guards:

  • the count was not folded away at zero weight: 3.2% mean and 4.0% best nps against a control build with the summand removed, over 21 interleaved runs on an identical tree; the commit had claimed the fold. Fixed with a SCORED const derived from the weights gating the summand, mobility's pattern, and measured again: -0.1% at a 5.6% spread
  • two of the nine mutations survived: pawns counted into a column, and a union per kind in place of a count per piece. Both are pinned now, by a pawn on c6 covering d7 and two rooks on a7 and h7 reading six
  • one mirrored row compared the side with no pieces on both boards, so it asserted zero against zero; the fens were in the wrong order, and a guard now fails a row counted from an empty side
  • a retained sentence said the tuner's identity would catch a term dropped from the sum; at zero weight it cannot, and the sentence says so now
  • the python pricing tests covered three blocks and not the fourth; the fourth has one, and the pawn structure test's range stops at the block after it
  • the commit body called all four bounds exact; a bishop shows at most two and a queen five, so three and six are given with a square in hand
  • ARCHITECTURE.md still counted three leaf terms; "the other two leaf terms" in the new file were three; the same clause twice in one file; four ragged lines in INSTRUMENTS.md

Every one of the nine mutations is caught after the fold, and a non-zero weight moves the bench to 3937024 and flips SCORED. Airgap and trailers checked.

@github-actions

Copy link
Copy Markdown
Contributor

Speed against 81093868

Both sides built and run on this runner in this job, the way
scripts/speed.sh measures a perf commit. The change is between
medians, and the spread beside it is what the change has to be
read against. The node counts are the search's: they move when
the search does, and a speed change leaves them alone.

round     base nps  candidate nps
    1      7862596        8099571
    2      8070629        8101657
    3      7920056        7966993
    4      8004905        7965877
    5      8118402        7818725
    6      8007688        7913721
    7      7887167        7911310
    8      8019323        8000469
    9      8000171        8006297

             nodes    time  median nps  fastest nps
base       3868987  0.48 s     8004905      8118402
candidate  3868987  0.49 s     7966993      8101657
change                           -0.5%        -0.2%

the change is inside the spread, so the medians make no claim; the
fastest column is the steadier comparison when the machine was not
quiet

Speed: -0.5% (bench nps, 9 interleaved rounds vs 81093868, spread 3.6%)

@aywrite
aywrite merged commit 188297f into master Sep 15, 2026
19 of 20 checks passed
@aywrite
aywrite deleted the eval/king-attack branch September 15, 2026 12:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant