Skip to content

Engine: SEE-pruned quiescence (static exchange evaluation) (closes #212) - #219

Open
testtest126 wants to merge 3 commits into
mainfrom
feature/see-quiescence
Open

Engine: SEE-pruned quiescence (static exchange evaluation) (closes #212)#219
testtest126 wants to merge 3 commits into
mainfrom
feature/see-quiescence

Conversation

@testtest126

@testtest126 testtest126 commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Closes #212.

Honest result up front: this is NOT a demonstrated strength improvement

A properly-powered SPRT (800 games, full schedule, did not resolve either way) found no measurable Elo effect: -1 ± 16 Elo, essentially a coin flip. Unlike LMR (#218), which resolved cleanly positive, this one genuinely didn't move the needle. I'm opening the PR as requested (correct, tested implementation with real infra value), but not recommending it merge as a strength win — that call is the orchestrator's/owner's, with this data in front of them. Details and a plausible explanation below.

What

Adds Board.see(_:) (ChessKit/Sources/ChessKit/Board.swift) — the standard "swap algorithm" Static Exchange Evaluation over the mailbox board: net material result of the full capture/recapture sequence on a square, least-valuable-attacker order each step. X-ray/battery attackers are picked up for free — attackers(to:by:excluding:) recomputes sliding attacks from scratch against the reduced occupancy at every step of the exchange, so a piece revealed behind a captured blocker is naturally found without separate bookkeeping.

Wires it into Search.quiesce (ChessKit/Sources/ChessProtocol/NegamaxEngine.swift, ~:493-530): out of check, a capture with see(move) < 0 is skipped rather than searched. Promotions are exempt (SEE only prices the destination-square material exchange, not the new queen). Never applied while in check — the existing "search every evasion" branch is untouched.

Adds a seePruning: Bool toggle (default true) to NegamaxEngine/PersistentNegamaxEngine/EngineConfig, and --baseline-see/--candidate-see/--candidate-evaluator default flags to EngineLab's abtest CLI, mirroring the lateMoveReductions seam from #218 — this is what let the heuristic be isolated and SPRT-measured against an otherwise-identical baseline.

Re-pins the EngineLabTests bench determinism signature.

SEE correctness — SEETests.swift

Six hand-verified cases: a clean undefended win (+100), a losing queen-takes-pawn-defended-by-pawn (-800), an equal rook trade (0), en passant (whose victim isn't on move.to — a naive implementation would score it as a free capture), a no-recapture edge case, and a battery/x-ray position specifically constructed to discriminate a correct implementation from a naive one: two White rooks doubled on a file, only the front one initially able to reach the target square, with Black holding one defending rook. Correct answer (x-ray-aware): +500. A SEE that only looked at attackers before any capture (missing the rear rook, since it starts blocked) would return 0 instead — this is the actual bug the test catches, not just a smoke check. All 6 pass.

SPRT result

abtest, DefaultEvaluator held fixed both sides (--candidate-evaluator default), 15,000-node budget both sides (equal-budget, not equal-depth — matches the LMR methodology), 400 openings sampled (seed 1) from testtest126/books' noob_3moves.epd, --elo0 0 --elo1 10:

games: 800 (full schedule — SPRT never resolved)
candidate (SEE on) results: +179 =440 -181   score: 49.9%
Elo(candidate - baseline): -1 ± 16  (95%)
SPRT: llr -0.71 (bounds -2.94 .. 2.94) -> inconclusive — keep playing

Node-count effect — real, but inconsistent (helps explain the neutral SPRT)

Measured via a local scratch probe against the built library (not committed), SEE-on vs SEE-off at equal depth:

position depth 3 depth 4 depth 5 depth 6
startpos (quiet) 1.00x 1.02x 1.01x 1.05x
middlegame 1.27x 1.51x 1.38x 0.76x (more nodes)
kiwipete (dense tactics) 2.07x 1.95x 1.69x 1.78x
tactical 1.14x 1.24x 1.12x 1.13x

SEE pruning genuinely cuts nodes in capture-rich positions (kiwipete: consistently 1.7-2.1x fewer) and barely matters in quiet ones (startpos), as expected. But it's not monotonic — at middlegame/depth 6 it used more nodes than the unpruned search, from pruning interacting with move ordering and alpha-beta cutoffs elsewhere in the tree (a real, known effect, not a bug — confirmed by SEEQuiescenceTests passing, including a dedicated testSEEPruningReducesNodes on a different fixture where the reduction does hold).

Plausible explanation for the neutral SPRT: quiescence is a much smaller, shallower slice of total search cost than the whole main tree LMR (#218) prunes recursively at every ply — LMR's node savings compound across the tree, SEE's don't. The captures SEE prunes are also often ones MVV-LVA ordering already pushes late and alpha-beta already cuts off cheaply, so the marginal compute actually freed may be smaller than the raw "captures skipped" count suggests — leaving little left over to reinvest into useful extra depth at this node-budget regime.

Verified locally at 863398f (this PR's exact head), in a detached scratch worktree

  • ChessKit: swift test (release, and separately confirmed in debug for the bench signature + both new test files) — EXIT:0
  • chess-server: swift testEXIT:0 (99 tests; unaffected by this change, verified for the record)
  • ios-chess-client: xcodebuild build (iPhone 15 Simulator, iOS 17.5) — EXIT:0

Baseline note

This was tested against current main (f80248c), which does not yet include #218 (LMR, still unmerged as of this writing). If/when both merge, the combined effect should be re-validated — LMR and SEE-pruned quiescence touch different parts of the same search and aren't guaranteed to be perfectly additive (LMR changes which nodes get reduced-depth looks in the main tree; SEE changes which captures quiescence bothers searching at all — plausible interactions either way).

Scope / classification

  • Not security-sensitive (pure engine search algorithm) — opening ready for review, not draft.
  • Does not touch the app UI, ChessOnline, or ChessKit/Sources/ChessOnline/Messages.swift.
  • Only touches what SEE needs: Board.swift, NegamaxEngine.swift, PersistentNegamaxEngine.swift, EngineLab/CLI.swift, EngineLab/SelfPlay.swift, EngineLabTests/BenchTests.swift (re-pin), and the two new test files. Does not touch PR Server: move the auth rate limiter to a shared sliding-window store (closes #79) #116 or the LMR branch (feature/lmr-search) — separate worktree, separate branch off origin/main.

Per repo convention, requesting the orchestrator review this — not merging it myself, and given the inconclusive result, it may be one for the owner to explicitly decide on rather than a routine merge.

Adds Board.see(_:) — the standard "swap algorithm" Static Exchange
Evaluation over the mailbox board: net material result of the full
capture/recapture sequence on a square, least-valuable-attacker order,
with x-ray/battery attackers picked up automatically (attackers are
recomputed from scratch against the reduced occupancy at each step, so a
piece revealed behind a captured blocker is naturally found — no separate
x-ray bookkeeping needed). Unit-tested against hand-verified cases: a
clean win, a losing queen-takes-defended-pawn, an equal rook trade, a
battery/x-ray position specifically chosen to discriminate a correct
implementation from one that only looks at pre-capture attackers, en
passant (whose victim isn't on the destination square), and a
no-recapture edge case.

Wires it into Search.quiesce (NegamaxEngine.swift): out of check, a
capture with SEE < 0 is skipped rather than searched — losing the
exchange can't beat standing pat, so confirming that by search is wasted
work. Promotions are exempt (SEE only prices the destination-square
exchange, not the new queen). Never applied while in check.

Adds a seePruning: Bool toggle (default true) to NegamaxEngine/
PersistentNegamaxEngine/EngineConfig, and --baseline-see/--candidate-see/
--candidate-evaluator default flags to EngineLab's abtest CLI, mirroring
the lateMoveReductions seam from #218/PR feature/lmr-search — this is
what let the heuristic be isolated and SPRT-measured.

Re-pins the EngineLabTests bench determinism signature.

SPRT (abtest, DefaultEvaluator fixed both sides, 15,000-node budget both
sides, 400 openings sampled from testtest126/books' noob_3moves.epd,
seed 1, elo0=0/elo1=10): full 800-game schedule played without
resolving. SEE +179 =440 -181 (49.9%), Elo -1 ± 16 (95%), llr -0.71 vs
bounds -2.94..2.94 -> inconclusive.

This is an honest non-result, not a win: unlike LMR (#218), SEE-pruned
quiescence shows no measurable Elo effect at this node budget/depth
regime, despite implementing correctly (per the unit tests) and reducing
node count meaningfully in tactical positions (kiwipete: 1.7-2.1x fewer
nodes across depths 3-6) — but inconsistently (middlegame fixture: 1.27-
1.51x fewer at depths 3-5, briefly *more* nodes at depth 6, 0.76x, from
pruning interacting with move ordering/alpha-beta cutoffs elsewhere in
the tree) and only mildly on quieter positions (startpos: ~1.00-1.05x).
Plausible explanation: quiescence is a much smaller, shallower slice of
total search cost than the whole main tree LMR prunes recursively, so
even a real per-node speedup there reinvests into comparatively little
extra useful depth. Not recommending this merge as a strength
improvement as-is; flagging for the orchestrator/owner to decide whether
to shelve or take a different angle (SEE-based capture *ordering* rather
than pruning, a wider EPD sample, or re-testing once combined with #218).

Closes #212.
@testtest126

Copy link
Copy Markdown
Owner Author

Merge request: PR #219 @ 863398f
Verified locally: ChessKit EXIT:0, server EXIT:0, iOS build EXIT:0 @ 863398f
Security-sensitive: no
App-touching: no (engine-only; GameSession.swift's one call site unaffected — new seePruning param defaults to true)
Shared files: none (does not touch ChessKit/Sources/ChessOnline/Messages.swift)

SPRT (abtest, DefaultEvaluator fixed both sides, equal 15k-node budget both sides, 400 openings sampled from testtest126/books' noob_3moves.epd, seed 1, elo0=0/elo1=10): full 800-game schedule played, SEE +179 =440 -181 (49.9%), Elo -1 ± 16 (95%), llr -0.71 vs bounds -2.94..2.94 -> inconclusive.

Flagging explicitly: this is NOT a demonstrated strength win, unlike #218. Implementation is correct (SEETests pass, including an x-ray-discriminating case) and reduces quiescence node count meaningfully in tactical positions (kiwipete: 1.7-2.1x fewer nodes), but that didn't translate to a measurable Elo gain at this node-budget regime. Recommend the owner decide explicitly whether to merge anyway (correctness/infra value), hold for a different angle (SEE-based move ordering instead of pruning, wider sample), or shelve — not a routine green-light merge like #218.

(Posted as a PR comment per the open-pr skill's fallback — same as PR #218, couldn't confidently identify the orchestrator session from list_sessions.)

@testtest126
testtest126 enabled auto-merge (squash) August 10, 2026 19:55
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.

Engine: SEE-pruned quiescence (static exchange evaluation)

1 participant