engine/skill.h¶
Strength limiting — the difficulty ladder’s weakening layer.
The public dial is UCI_Elo (with UCI_LimitStrength); everything else here is a primitive the calibration harness sweeps. Rationale, the rung table’s provenance and the calibration method live in docs/skill-ladder-docs/PLAN.md.
The layer sits entirely outside negamax(): searchPosition() runs a normal search and then hands the result to applySkill(), which may substitute a different move. That boundary is deliberate — with UCI_LimitStrength off the engine is bit-identical to one built without this file, which is what protects the top rung and the rating-list builds from ladder work.
Functions
-
void setSkillLimitStrength(bool enabled)¶
-
bool getSkillLimitStrength()¶
-
void setSkillUciElo(int elo)¶
-
int getSkillUciElo()¶
-
void setSkillLevelAlias(int level)¶
The legacy 0/1/2 option, reimplemented as an alias over UCI_Elo.
Kept at its original range on purpose: the value is persisted in the personality JSON files and in saved GUI settings, so widening the range would silently change what an existing
2means.
-
void setSkillPerceptionDepth(int depth)¶
-
void setSkillMistakePct(int pct)¶
-
void setSkillLapseDepth(int depth)¶
-
void setSkillLapsePct(int pct)¶
-
void setSkillErrorTopK(int k)¶
-
void setSkillErrorTemperatureCp(int centipawns)¶
-
void setSkillMaxLossCp(int centipawns)¶
-
void setSkillCatastropheCapCp(int centipawns)¶
-
void setSkillTopK(int k)¶
-
void setSkillTemperatureCp(int centipawns)¶
-
void setSkillScanReservePct(int pct)¶
-
void setSkillJitterCp(int centipawns)¶
-
void setSkillNodeCap(int nodes)¶
-
int getSkillPerceptionDepth()¶
-
int getSkillMistakePct()¶
-
int getSkillLapseDepth()¶
-
int getSkillLapsePct()¶
-
int getSkillErrorTopK()¶
-
int getSkillErrorTemperatureCp()¶
-
int getSkillMaxLossCp()¶
-
int getSkillCatastropheCapCp()¶
-
int getSkillTopK()¶
-
int getSkillTemperatureCp()¶
-
int getSkillScanReservePct()¶
-
int getSkillJitterCp()¶
-
int getSkillNodeCap()¶
-
void setSkillSeed(unsigned int seed)¶
Seed the selector’s RNG. 0 means seed from the clock at the next search.
The engine’s global RNG is seeded to a constant and never reseeded, which is correct for magic-number generation but would make a rung play the identical mistake in the identical position every game. The harness sets a fixed seed for reproducibility; the product leaves it at 0.
-
unsigned int getSkillSeed()¶
-
bool skillActive()¶
True when the layer will alter play. False ⇒ searchPosition behaves as it always did.
-
const SkillParams &skillParams()¶
The resolved parameters for the current rung.
-
void skillBeginSearch()¶
Apply the rung’s node cap and hold back the scan’s share of the budget.
Called once at the top of searchPosition(). Reserving up front rather than spending extra afterwards is what keeps
go nodes Nmeaning N — which the whole calibration method depends on.
-
int applySkill(int engineBest, int engineScore)¶
Possibly substitute a weaker move for the engine’s choice.
Restores the budget reserved by skillBeginSearch() and leaves board state unchanged.
- Parameters:
engineBest – the move the search settled on
engineScore – that move’s score, side-to-move relative
- Returns:
the move to play; always legal, and equal to
engineBestwhen the layer is inactive or the rung happens not to err this move.
Variables
-
constexpr int kSkillFullStrength = 0¶
The explicit “no rung” strength request: the whole engine, no node cap.
Not a rating, never interpolated, and deliberately below
UCI_Elo’s spin minimum of 250 so no UCI caller can land on it by accident. The two unnumbered cards (Virtuoso, Kirin) ask for full strength by this name.They used to get it by not naming a rung at all — an absent JSON field resolving through a zero default — and the layer’s own off-switch was likewise a comparison,
uciElo < 2300, whose exclusive boundary silently exempted rung 2300 as well: on the board, under a clock, the top numbered card searched the full 3.9 M nodes against a table row promising 800k (docs/skill-ladder-docs/PLAN.md§5.10). Encoding “uncapped” as a fallthrough is what produced that bug, so it is a value now, stated at every layer that carries it.
-
constexpr int kTopRungElo = 2300¶
The ladder’s top numbered rung. A request at or below this is a rung.
Public because the boundary is part of the contract rather than an implementation detail: it decides which requests get a node cap, and it was exclusive once, which is how the top rung came to have none.
SkillTestasserts the inclusivity directly.
-
struct SkillParams¶
- #include <skill.h>
The weakening knobs for one rung, after interpolation from the Elo table.
Centipawn fields are side-to-move relative, matching search scores.
Public Members
-
int nodeCap = 0¶
Self-imposed node budget; 0 = no cap.
-
int perceptionDepth = 0¶
How far ahead this rung looks when it decides, as a mode, not a depth.
Depth alone cannot reach the bottom of the ladder. A depth-0 open-window search falls through to quiescence, which resolves every capture sequence and answers checks — so it finds mates in one and never hangs a piece to a one-mover. That is sharp play, not beginner play. The bottom of a real ladder has to not look at the reply at all, which is the single most characteristic beginner error.
0 — static evaluation after the move; the reply is never considered 1 — plus the opponent’s best single capture; the obvious recapture is seen 2 — full quiescence; all capture sequences resolve d ≥ 3 — quiescence plus (d - 2) plies of real search
-
int mistakePct = 0¶
Chance per move of deciding on the shallow view.
-
int lapseDepth = 0¶
A blinder mode to drop to on some mistakes, and how often to drop to it.
One step of
perceptionDepthis worth on the order of 1000 Elo — measured, seedocs/skill-ladder-docs/PLAN.md§5.4 — so a table that gives each rung a single mode cannot express anything between two rungs. That is the cliff that made rung 500 unable to beat Stockfish’s 1320 floor while rung 800 played at ~1850.Mixing two modes makes the ladder continuous where a single one is a staircase: a lapse is a move the player did not look at, and how often a player simply fails to look is exactly the quantity that separates human bands (§5.3 — across 1800 Elo, error frequency moves 4.7x while error size barely moves).
lapsePct0 disables the mix, which is what makes the mechanism inert for any rung that does not ask for it. Perception mode used on a lapse; must be < perceptionDepth
-
int lapsePct = 0¶
Chance a mistake is a lapse. 0 = never.
-
int errorTopK = 1¶
How far down its own shallow ranking a rung will pick when it errs.
Without this the mistake branch plays
candidates[0]— the best move under the shallow view — and that is why the bottom of the ladder was unreachable. A one-ply NNUE ranking is good enough that its favourite rarely loses much: rung 500 at its lossiest other setting measured ACPL 68 against a band target of 112.6, and raisingjitterCpfifteenfold only reached 72.7 (PLAN.md §5.5). Perturbing scores only reorders near-ties; choosing rank 1 or 2 outright is a far larger effect.A beginner is not someone who reliably finds the best move under a blind criterion.
Deliberately separate from
topK/temperatureCp, which serve the non-mistake branch and exist for opening variety at the top rungs. One pair has to grow as the other shrinks, so sharing them would couple two things that move in opposite directions. Shallow candidates the mistake branch may pick among
-
int errorTemperatureCp = 0¶
Spread of that choice; 0 = always the shallow best.
-
int maxLossCp = -1¶
Scale of tolerated centipawn loss; -1 = no limit.
-
int catastropheCapCp = 0¶
Hard ceiling on tolerated loss; 0 with maxLossCp<0.
-
int topK = 1¶
Root moves the good-branch sampler may choose among.
-
int temperatureCp = 0¶
Spread of that sampler; 0 = always the best move.
-
int scanReservePct = 0¶
Share of the budget held back to pay for the scan.
-
int jitterCp = 0¶
Tie-break noise on shallow scores.
-
int nodeCap = 0¶