Skip to content

v1.0.49

Choose a tag to compare

@github-actions github-actions released this 01 Aug 06:14
· 28 commits to main since this release

Time-management fix — v1.0.48 is withdrawn, use this instead

v1.0.48 has been converted to a draft release and should not be used. The Lichess bot lost 31 of its last 33 rated games on the clock while running it (bullet −352, blitz −354). This release corrects the underlying defect.

What was wrong

The engine routinely spent 2–4× its intended (soft) time budget on a move. Two independent defects, both pre-existing — v1.0.47 has them too, so this is not a v1.0.48 regression.

1. No iteration could be stopped once it had started. check_time! and time_expired! both test end_time — the hard limit. soft_time_limit was only tested between iterations. So starting an iteration that could not finish silently promoted that move from the soft budget to the hard ceiling, which is TM_HARD_FACTOR / TM_SOFT_FACTOR = 4.2× larger. At 60+0.6 that is a 1462ms budget against a 6095ms ceiling — a tenth of an entire bullet game on one move.

2. The soft-limit extensions compounded without bound. TM_INSTABILITY_EXTEND (1.3) and TM_SCORE_DROP_EXTEND (1.5) are applied once per iteration, each clamped only against end_time. On a position where the best move keeps changing they compound geometrically and saturate the soft limit at the hard limit. Measured a move costing exactly 4.17× soft — which is 2.5/0.6 to three digits.

Notably, there was no overshoot bug: the hard limit is honoured to within 1–3ms. check_time! works correctly. The defect was entirely that the soft limit is unenforceable mid-iteration.

The fix

  • next_iteration_fits() — predicts the next iteration's cost from the measured cost of the last one and declines to start it if it will not fit (TM_ITERATION_GROWTH). Pure, so the decision is unit-testable.
  • TM_MAX_EXTENSION_FACTOR — a cumulative ceiling on soft-limit extension, fixed against the original soft budget before any iteration can move it.

Both were needed: the first alone moved the median from 1.95× to 1.76×; the second took it to 1.00×.

Measured

At 1+0, 8 threads, 80 plies — median time spent per move as a multiple of the soft budget, and clock remaining:

build median spent/soft clock left
v1.0.47 2.06× 8.4s
v1.0.48 2.03× 8.4s
v1.0.49 1.00× 22.1s

The hard limit remains an emergency ceiling and is still reached occasionally — the
predictive cutoff acts between iterations, so a single iteration that starts inside
budget can still overrun to it. That is by design. What changes is how often: the
typical move now costs its intended budget rather than roughly double it, which is
what drains a clock over a game.

The same measurement across six time controls, v1.0.47 vs v1.0.49 — median time spent per move as a multiple of the soft budget, and clock remaining after 80 plies (60 at 5+3, 40 at 10+0), 8 threads:

TC v1.0.47 v1.0.49 v1.0.47 clock left v1.0.49 clock left
1+0 1.97× 1.10× 9.9s / 13.6s 22.4s / 22.2s
2+1 2.18× 1.17× 9.0s / 13.6s 34.3s / 46.6s
3+0 2.35× 1.06× 26.0s / 23.3s 65.1s / 71.5s
3+2 1.73× 1.10× 21.4s / 30.5s 100.7s / 67.5s
5+3 1.92× 1.46× 57.0s / 75.7s 122.3s / 121.3s
10+0 2.20× 1.11× 203.6s / 264.0s 385.2s / 365.2s

v1.0.47 overspends at every control without exception. 5+3 is v1.0.49's weakest result at 1.46×, where the extension ceiling rather than the predictive cutoff is the binding constraint.

Matches

match result forfeits
1+0, 200 games, vs v1.0.48-equivalent (no fix) −17.4 ± 36.8, LOS 17.7% — neutral 0 / 0
10+0 rapid, 100 games, vs v1.0.47 +49.0 ± 43.0, LOS 1.3% — significantly stronger 0 / 0
2+1, 24 games, vs v1.0.47 +58.5 ± 91.2 — uninformative 0 / 0
3+2, 24 games, vs v1.0.47 −104.4 ± 116.8 — uninformative 0 / 0

All at Threads=8, concurrency 1. Zero time forfeits in 348 games.

The rapid result confirms v1.0.48's search work (LMP to depth 8, razoring, bad-capture staging, TT static_eval) plus the NNUE output buckets are genuinely worth keeping — that +49 is the cumulative gain since v1.0.47, not the time fix, which measures neutral on its own. Halving the time spent per move costs no measurable strength, which is the point: that time was being wasted.

Bench signature is unchanged at 11,015,729 — the fix is gated on there being a real clock, so fixed-depth and movetime searches are provably unaffected.

Honest limits

  • This is not proven to cure the Lichess forfeits. No local harness reproduced a forfeit on any build, including the unfixed ones, because per-move budgets are recomputed from the remaining clock and therefore decay geometrically toward zero rather than crossing it. The live failure likely also involves network latency or bot overhead charged to the clock. The defect corrected here is measured and real; the claim that it fixes live flagging is not yet evidenced.
  • The v1.0.48 attribution for the original symptom remains unproven. The overspend predates it by at least one release — v1.0.47 overspends at all six controls above.
  • Blitz strength is unmeasured. The two 24-game legs were sized to detect forfeits, not Elo, and returned +58.5 and −104.4 — opposite directions, both spanning zero. There is no indication of a regression, but no positive evidence either. Bullet and rapid are properly measured; blitz is not.
  • TM_ITERATION_GROWTH and TM_MAX_EXTENSION_FACTOR are first-guess values, not SPSA-tuned. There is probably Elo in tuning them, particularly at 5+3.

Testing

The reason this shipped in the first place: every timing assertion in the suite checked only the hard limit, which the engine honours to 1ms while spending 4× its budget. Added it_respects_the_soft_time_limit, verified to fail without the fix at 6070ms against a 1462ms soft limit — while 6070ms still passes the old 6095ms hard-limit assertion.

Also added scripts/ab_time_match.sh: Threads=8, Hash=512, Move Overhead=0, concurrency pinned to 1, zero increment. The existing ab_match.sh runs Threads=1 with a 200ms timemargin at 10+0.1 — overspend scales with thread count (2.13× at 1 thread vs 2.88× at 8) and is masked by an increment, so it could not have caught this.