From b7de1424ae4d7a0f213e4b73f9b3e7fb8f01a236 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 11:53:54 +0000 Subject: [PATCH] feat(uci): Let a depth under way run past the share to a hard limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A share of a game clock was also the deadline, so a depth begun under the soft line and still running at the share was cut there. Now a share carries a limit beside it: three shares, at most a quarter of the clock, less the overhead, and never under the share. The search's deadline is the limit, while the soft line still reads the share, so no depth is begun on the extra time and only one already under way runs on. To keep the time spent level the share is 81% of what it was, taken before the overhead (about 0.79 after it at 10+0.1). Over 100 games of a 10+0.1 match replayed at a node budget, the search spent 0.71 of each share stopping at it and 0.91 running on, and with the share at 0.813 of the old one the replayed clocks end at the same mean. A move time and an increment with no clock are unchanged and have nothing past their budget. The same replay, each changed move judged by a deeper search of the position after it, put the value per extra node of running on at 1.81 times that of a share half again as large on every move (95% 0.92 to 3.54). Played at 10+0.1 against master d82638a, it passed sprt [0, 10] at LLR 4.93: +32 ±14 over 1,000 games in two batches, which read +31 and +33. The estimate is a stopped sequential one, so it leans high. The candidate had 1.009 of master's time and ended 115 ms lower (95% -148 to -83), with no losses on time. A fixed 1,000 games at 30+0.3 read +13 ±14 (95% -1 to +27) at 1.001 of the time, also with no losses on time. The bench is fixed depth and reads no clock, so it is unchanged. Bench: 11088536 Elo: +32 ±14 (sprt [0, 10] passed, 1000 games, 10+0.1, vs d82638a) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011saZHGHgVvBWxJ7nougTPN --- arche-core/src/engine/tests.rs | 12 +-- arche-core/src/limits.rs | 76 ++++++++++++--- src/time_control.rs | 168 ++++++++++++++++++++++++--------- src/uci.rs | 23 +++-- 4 files changed, 205 insertions(+), 74 deletions(-) diff --git a/arche-core/src/engine/tests.rs b/arche-core/src/engine/tests.rs index 25511aef..43525a0f 100644 --- a/arche-core/src/engine/tests.rs +++ b/arche-core/src/engine/tests.rs @@ -1130,7 +1130,7 @@ mod search { fn already_spent() -> Limits { Limits::starting_at( time::Instant::now() - time::Duration::from_secs(1), - Some(Clock::Share(time::Duration::from_millis(1))), + Some(Clock::share(time::Duration::from_millis(1))), u64::MAX, ) } @@ -1528,7 +1528,7 @@ mod search { None, Limits::starting_at( time::Instant::now() - time::Duration::from_secs(1), - Some(Clock::Share(time::Duration::from_millis(1))), + Some(Clock::share(time::Duration::from_millis(1))), 1_000_000, ), ); @@ -1543,7 +1543,7 @@ mod search { let options = SearchParameters::new( None, Limits::starting_now( - Some(Clock::Share(time::Duration::from_secs(10))), + Some(Clock::share(time::Duration::from_secs(10))), Some(limit), ), ); @@ -1730,7 +1730,7 @@ mod search { let mut e = engine(Board::new()); let params = SearchParameters::new( None, - Limits::starting_now(Some(Clock::Share(time::Duration::from_millis(50))), None), + Limits::starting_now(Some(Clock::share(time::Duration::from_millis(50))), None), ); let outcome = e.iterative_deepening_search(params, |_, _, _, _| {}); assert!(matches!(outcome, SearchOutcome::Aborted(Some(_)))); @@ -1742,7 +1742,7 @@ mod search { // itself has not. The fraction belongs to Limits; this asserts the // deepening loop asks it, and only of a share of a game clock for (kind, depths) in [ - (Clock::Share as fn(time::Duration) -> Clock, vec![1]), + (Clock::share as fn(time::Duration) -> Clock, vec![1]), (Clock::Fixed as fn(time::Duration) -> Clock, vec![1, 2]), ] { let mut e = engine(Board::new()); @@ -1778,7 +1778,7 @@ mod search { Some(2), Limits::starting_at( time::Instant::now() - time::Duration::from_millis(gone), - Some(Clock::Share(time::Duration::from_secs(1))), + Some(Clock::share(time::Duration::from_secs(1))), u64::MAX, ), ); diff --git a/arche-core/src/limits.rs b/arche-core/src/limits.rs index 20a73afe..42bc4240 100644 --- a/arche-core/src/limits.rs +++ b/arche-core/src/limits.rs @@ -55,8 +55,7 @@ const UNSETTLED_PERMILLE: u128 = 500; const TABLE_ANSWERED_PERMILLE: u128 = 100; /// The line after a depth that chose another move than the depth before -/// it, whatever its nodes say. Still under the deadline, which stays the -/// share. +/// it, whatever its nodes say. Still under the share. const CHANGED_PERCENT: u128 = 65; /// How a completed depth spent its nodes at the root, which is what the @@ -99,17 +98,36 @@ impl RootNodes { /// exactly that much thinking and there is nothing to save it for. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum Clock { - /// A share of a running game clock, worked out from what is left of it. - Share(Duration), + /// A share of a running game clock, worked out from what is left of it, + /// and the limit a depth begun inside the share may run on to. + Share { share: Duration, limit: Duration }, /// A time the caller named, spent as named. Fixed(Duration), } impl Clock { + /// A share with no room past it. + pub fn share(share: Duration) -> Self { + Clock::Share { + share, + limit: share, + } + } + + /// What the move is planned to take: the share, or the time named. The + /// soft line is read against it. + pub fn budget(self) -> Duration { + match self { + Clock::Share { share, .. } => share, + Clock::Fixed(budget) => budget, + } + } + /// How long the search may run, whichever kind of clock this is. pub fn deadline(self) -> Duration { match self { - Clock::Share(budget) | Clock::Fixed(budget) => budget, + Clock::Share { limit, .. } => limit, + Clock::Fixed(budget) => budget, } } } @@ -172,7 +190,7 @@ impl Limits { match self.clock { // nanoseconds in a u128: multiplying the durations themselves // panics on a clock large enough to overflow - Some(Clock::Share(budget)) => { + Some(Clock::Share { share: budget, .. }) => { self.started.elapsed().as_nanos() * 100 < budget.as_nanos() * last.soft_line_percent() } @@ -248,7 +266,7 @@ mod tests { fn already_spent() -> Limits { Limits::starting_at( Instant::now() - Duration::from_secs(1), - Some(Clock::Share(Duration::from_millis(1))), + Some(Clock::share(Duration::from_millis(1))), u64::MAX, ) } @@ -300,7 +318,7 @@ mod tests { #[test] fn nothing_is_armed_until_a_depth_has_been_answered() { - let limits = Limits::starting_at(Instant::now(), Some(Clock::Share(Duration::ZERO)), 10); + let limits = Limits::starting_at(Instant::now(), Some(Clock::share(Duration::ZERO)), 10); let first = limits.for_iteration(false, 0); assert!(!first.expired(1_000_000), "depth one was stoppable"); } @@ -332,11 +350,11 @@ mod tests { // any scheduling stall that long let soft = Duration::from_millis(SOFT_LIMIT_PERCENT as u64 * 10); assert!( - a_second_of(Clock::Share, soft - Duration::from_millis(50)) + a_second_of(Clock::share, soft - Duration::from_millis(50)) .worth_another_iteration(answered()) ); assert!( - !a_second_of(Clock::Share, soft + Duration::from_millis(50)) + !a_second_of(Clock::share, soft + Duration::from_millis(50)) .worth_another_iteration(answered()) ); } @@ -383,7 +401,7 @@ mod tests { fn the_root_moves_the_line_an_iteration_is_begun_under() { // each elapsed share sits at least five points from every line it // is read against, since the call reads the clock again - let at = |percent: u64| a_second_of(Clock::Share, Duration::from_millis(percent * 10)); + let at = |percent: u64| a_second_of(Clock::share, Duration::from_millis(percent * 10)); // a settled root gives up where the line before it did not assert!(at(38).worth_another_iteration(answered())); assert!(!at(38).worth_another_iteration(Some(took(950)))); @@ -391,7 +409,7 @@ mod tests { assert!(!at(52).worth_another_iteration(answered())); assert!(at(52).worth_another_iteration(Some(took(400)))); assert!(!at(52).worth_another_iteration(Some(took(50)))); - // and a changed move further still, short of the deadline + // and a changed move further still, short of the share let changed = RootNodes { changed: true, ..took(950) @@ -405,7 +423,35 @@ mod tests { // an elapsed share that a clock budget would refuse is still begun let late = Duration::from_millis(990); assert!(a_second_of(Clock::Fixed, late).worth_another_iteration(answered())); - assert!(!a_second_of(Clock::Share, late).worth_another_iteration(answered())); + assert!(!a_second_of(Clock::share, late).worth_another_iteration(answered())); + } + + #[test] + fn a_depth_under_way_runs_past_the_share_to_its_limit() { + // a second and a half into a share of a second that may run to two + let limits = Limits::starting_at( + Instant::now() - Duration::from_millis(1_500), + Some(Clock::Share { + share: Duration::from_secs(1), + limit: Duration::from_secs(2), + }), + u64::MAX, + ); + assert!(!limits.expired(0), "the share was read as the deadline"); + assert!( + !limits.worth_another_iteration(answered()), + "a depth was begun past the share" + ); + } + + #[test] + fn every_iteration_keeps_the_limit_past_the_share() { + let clock = Clock::Share { + share: Duration::from_secs(1), + limit: Duration::from_secs(3), + }; + let limits = Limits::starting_now(Some(clock), None); + assert_eq!(limits.for_iteration(true, 5_000).clock(), Some(clock)); } #[test] @@ -428,7 +474,7 @@ mod tests { let started = Instant::now() - Duration::from_secs(1); let limits = Limits::starting_at( started, - Some(Clock::Share(Duration::from_secs(2))), + Some(Clock::share(Duration::from_secs(2))), u64::MAX, ); // the second iteration does not start the two seconds again @@ -437,7 +483,7 @@ mod tests { #[test] fn a_search_with_no_node_limit_never_takes_one_from_the_deepening() { - let limits = Limits::starting_now(Some(Clock::Share(Duration::from_secs(1))), None); + let limits = Limits::starting_now(Some(Clock::share(Duration::from_secs(1))), None); assert_eq!(limits.node_budget(), u64::MAX); assert_eq!(limits.for_iteration(true, 5_000).node_budget(), u64::MAX); } diff --git a/src/time_control.rs b/src/time_control.rs index b1422298..3b63a349 100644 --- a/src/time_control.rs +++ b/src/time_control.rs @@ -15,10 +15,10 @@ pub const DEFAULT_MOVE_OVERHEAD_MS: u64 = 50; /// /// This is a planning horizon, not an estimate of the moves left. Games here /// last about 65 moves a side after the book, so 20 falling to 10 is about -/// 0.3 of the moves left, on purpose: the search spends about 70% of each -/// share, and matches favoured spending faster than an even spread. At -/// equal time, a horizon set from a fitted estimate of the moves left did no -/// better (see docs/ROADMAP.md). +/// 0.3 of the moves left, on purpose: the search spends about 70% of the +/// clock over the horizon, and matches favoured spending faster than an even +/// spread. At equal time, a horizon set from a fitted estimate of the moves +/// left did no better (see docs/ROADMAP.md). /// /// With an increment the clock falls until a move's spend equals the /// increment and then holds, so the horizon decides how much clock is unspent @@ -47,6 +47,21 @@ const INCREMENT_PERCENT: u64 = 75; /// large increment from spending time we have not been given yet. const MAX_CLOCK_PERCENT: u64 = 33; +/// The part of the share above that a move on a game clock plans for, as a +/// percentage, taken before the overhead. Below the whole of it because a +/// depth under way at the share runs on to the limit: replayed over 100 +/// games at a node budget, the search spent 0.71 of each share stopping +/// there and 0.91 running on, and at 81% the replayed clocks end where they +/// did. With the overhead taken after, a share at 10+0.1 is about 0.79 of +/// what it was. +const SHARE_PERCENT: u64 = 81; + +/// The limit a depth begun inside the share may run on to: this many +/// shares, and no more than `LIMIT_CLOCK_PERCENT` of the clock. Only a depth +/// already under way uses it, since the soft line still reads the share. +const LIMIT_SHARES: u32 = 3; +const LIMIT_CLOCK_PERCENT: u64 = 25; + /// Searched even on a spent clock, so that a legal move comes back. const MIN_BUDGET_MS: u64 = 1; @@ -105,23 +120,34 @@ impl TimeControl { /// How long to search for, or `None` for no time limit, less the /// overhead. A move time is `Fixed`; a share worked out from the clock is - /// a guess the search may spend less of. + /// a guess the search may spend less of, with a limit past it that a + /// depth under way may run on to. pub fn budget(&self, overhead: u64) -> Option { if self.infinite { return None; } - let spend = match (self.move_time, self.time, self.increment) { - (Some(move_time), _, _) => move_time, - (None, Some(time), increment) => self.clock_share(time, increment.unwrap_or(0)), - // some interfaces send an increment with no clock - (None, None, Some(increment)) => percent(increment, INCREMENT_PERCENT), - (None, None, None) => return None, - }; - let budget = Duration::from_millis(spend.saturating_sub(overhead).max(MIN_BUDGET_MS)); - Some(match self.move_time { - Some(_) => Clock::Fixed(budget), - None => Clock::Share(budget), - }) + let less_overhead = + |millis: u64| Duration::from_millis(millis.saturating_sub(overhead).max(MIN_BUDGET_MS)); + match (self.move_time, self.time, self.increment) { + (Some(move_time), _, _) => Some(Clock::Fixed(less_overhead(move_time))), + (None, Some(time), increment) => { + let share = less_overhead(percent( + self.clock_share(time, increment.unwrap_or(0)), + SHARE_PERCENT, + )); + let limit = (share * LIMIT_SHARES) + .min(less_overhead(percent(time, LIMIT_CLOCK_PERCENT))) + .max(share); + Some(Clock::Share { share, limit }) + } + // some interfaces send an increment with no clock, and there is + // no clock to run on into + (None, None, Some(increment)) => Some(Clock::share(less_overhead(percent( + increment, + INCREMENT_PERCENT, + )))), + (None, None, None) => None, + } } fn clock_share(&self, time: u64, increment: u64) -> u64 { @@ -149,7 +175,7 @@ mod tests { fn millis_at(control: &TimeControl, overhead: u64) -> Option { control .budget(overhead) - .map(|clock| clock.deadline().as_millis() as u64) + .map(|clock| clock.budget().as_millis() as u64) } fn millis(control: &TimeControl) -> Option { @@ -199,7 +225,10 @@ mod tests { ); assert_eq!( clock(60_000).budget(DEFAULT_MOVE_OVERHEAD_MS), - Some(Clock::Share(Duration::from_millis(2_950))) + Some(Clock::Share { + share: Duration::from_millis(2_380), + limit: Duration::from_millis(7_140), + }) ); assert_eq!( TimeControl { @@ -207,14 +236,14 @@ mod tests { ..Default::default() } .budget(DEFAULT_MOVE_OVERHEAD_MS), - Some(Clock::Share(Duration::from_millis(700))) + Some(Clock::share(Duration::from_millis(700))) ); } #[test] fn the_overhead_is_whatever_the_option_was_set_to() { - assert_eq!(millis_at(&clock(60_000), 0), Some(3_000)); - assert_eq!(millis_at(&clock(60_000), 500), Some(2_500)); + assert_eq!(millis_at(&clock(60_000), 0), Some(2_430)); + assert_eq!(millis_at(&clock(60_000), 500), Some(1_930)); let move_time = TimeControl { move_time: Some(500), ..Default::default() @@ -251,8 +280,8 @@ mod tests { #[test] fn sudden_death_plans_for_twenty_more_moves_at_the_first_move() { - // 60000 / 20 - 50 - assert_eq!(millis(&clock(60_000)), Some(2_950)); + // 81% of 60000 / 20, less 50 + assert_eq!(millis(&clock(60_000)), Some(2_380)); } fn at_move(first: u64, now: u64) -> TimeControl { @@ -264,19 +293,20 @@ mod tests { #[test] fn the_horizon_falls_from_twenty_to_ten_by_move_sixty() { - // the first move searched, at 8moves' exit: 60000 / 20 - 50 - assert_eq!(millis(&at_move(9, 9)), Some(2_950)); - // 25 of 51 moves on, the horizon is 20 - 250/51: 60000 * 51 / 770 - 50 - assert_eq!(millis(&at_move(9, 34)), Some(3_924)); - // 60000 / 10 - 50, and no further - assert_eq!(millis(&at_move(9, 60)), Some(5_950)); - assert_eq!(millis(&at_move(9, 120)), Some(5_950)); + // the first move searched, at 8moves' exit: 81% of 60000 / 20, less 50 + assert_eq!(millis(&at_move(9, 9)), Some(2_380)); + // 25 of 51 moves on, the horizon is 20 - 250/51: 81% of + // 60000 * 51 / 770, less 50 + assert_eq!(millis(&at_move(9, 34)), Some(3_168)); + // 81% of 60000 / 10, less 50, and no further + assert_eq!(millis(&at_move(9, 60)), Some(4_810)); + assert_eq!(millis(&at_move(9, 120)), Some(4_810)); } #[test] fn a_first_move_at_or_past_sixty_falls_to_ten_a_move_later() { - assert_eq!(millis(&at_move(70, 70)), Some(2_950)); - assert_eq!(millis(&at_move(70, 71)), Some(5_950)); + assert_eq!(millis(&at_move(70, 70)), Some(2_380)); + assert_eq!(millis(&at_move(70, 71)), Some(4_810)); } #[test] @@ -285,7 +315,7 @@ mod tests { moves_to_go: Some(10), ..at_move(9, 9) }; - assert_eq!(millis(&control), Some(5_950)); + assert_eq!(millis(&control), Some(4_810)); } #[test] @@ -294,8 +324,8 @@ mod tests { moves_to_go: Some(10), ..clock(60_000) }; - // 60000 / 10 - 50 - assert_eq!(millis(&control), Some(5_950)); + // 81% of 60000 / 10, less 50 + assert_eq!(millis(&control), Some(4_810)); } #[test] @@ -313,8 +343,8 @@ mod tests { moves_to_go: Some(1), ..clock(60_000) }; - // the cap decides: 33% - 50 - assert_eq!(millis(&control), Some(19_750)); + // the cap decides: 81% of 33%, less 50 + assert_eq!(millis(&control), Some(15_988)); } #[test] @@ -323,8 +353,8 @@ mod tests { increment: Some(1_000), ..clock(60_000) }; - // 60000 / 20 + 750 of the increment - 50 - assert_eq!(millis(&control), Some(3_700)); + // 81% of 60000 / 20 + 750 of the increment, less 50 + assert_eq!(millis(&control), Some(2_987)); } #[test] @@ -338,12 +368,13 @@ mod tests { let budget = millis(&control).unwrap(); assert!(budget < time, "spent {} of {} left", budget, time); } - // near the floor 7913356 measured at 10+0.1: 1900 / 20 + 75 - 50 + // near the floor 7913356 measured at 10+0.1: 81% of 1900 / 20 + 75, + // less 50 let control = TimeControl { increment: Some(100), ..clock(1_900) }; - assert_eq!(millis(&control), Some(120)); + assert_eq!(millis(&control), Some(87)); } #[test] @@ -354,14 +385,61 @@ mod tests { increment: Some(100), ..clock(200) }; - // 33% of 200 is 66, which is less than 200 / 20 + 75 - assert_eq!(millis(&nearly_spent), Some(16)); + // 33% of 200 is 66, which is less than 200 / 20 + 75; 81% of it + assert_eq!(millis(&nearly_spent), Some(3)); let a_little_more = TimeControl { increment: Some(100), ..clock(300) }; // 300 / 20 + 75 is 90, which is under the 99 the cap allows - assert_eq!(millis(&a_little_more), Some(40)); + assert_eq!(millis(&a_little_more), Some(22)); + } + + fn limit_millis(control: &TimeControl) -> Option { + control + .budget(DEFAULT_MOVE_OVERHEAD_MS) + .map(|clock| clock.deadline().as_millis() as u64) + } + + #[test] + fn a_depth_under_way_may_run_on_to_three_shares() { + assert_eq!(limit_millis(&clock(60_000)), Some(3 * 2_380)); + } + + #[test] + fn the_limit_takes_no_more_than_a_quarter_of_the_clock() { + // a share of 81% of 60000 / 5, less 50, is 9670; three of them + // would pass a quarter of the clock, less 50 + let control = TimeControl { + moves_to_go: Some(5), + ..clock(60_000) + }; + assert_eq!(millis(&control), Some(9_670)); + assert_eq!(limit_millis(&control), Some(14_950)); + } + + #[test] + fn the_limit_is_never_below_the_share() { + // the share's own cap of a third is above a quarter + let control = TimeControl { + moves_to_go: Some(1), + ..clock(60_000) + }; + assert_eq!(limit_millis(&control), millis(&control)); + } + + #[test] + fn a_move_time_and_a_bare_increment_have_nothing_past_the_budget() { + let move_time = TimeControl { + move_time: Some(500), + ..clock(60_000) + }; + assert_eq!(limit_millis(&move_time), Some(450)); + let increment = TimeControl { + increment: Some(1_000), + ..Default::default() + }; + assert_eq!(limit_millis(&increment), Some(700)); } #[test] diff --git a/src/uci.rs b/src/uci.rs index 04bd8a4c..3667fd06 100644 --- a/src/uci.rs +++ b/src/uci.rs @@ -1410,7 +1410,7 @@ go depth 3 uci.engine.move_number = number; uci.handle("go wtime 60000"); let asked = uci.engine.asked.take().expect("no search"); - shares.push(asked.limits.clock().unwrap().deadline().as_millis() as u64); + shares.push(asked.limits.clock().unwrap().budget().as_millis() as u64); } } } @@ -1419,8 +1419,9 @@ go depth 3 #[test] fn the_horizon_falls_from_the_first_move_searched_on_a_clock() { - // a twentieth at move 9, a tenth from move 60, less the overhead - assert_eq!(shares_at_moves(&[Some(9), Some(60)]), [2_950, 5_950]); + // 81% of a twentieth at move 9 and of a tenth from move 60, less the + // overhead + assert_eq!(shares_at_moves(&[Some(9), Some(60)]), [2_380, 4_810]); // a move time does not start the game's horizon let mut uci = recording(); uci.engine.move_number = 50; @@ -1432,12 +1433,12 @@ go depth 3 fn a_new_game_or_an_earlier_move_starts_the_horizon_again() { assert_eq!( shares_at_moves(&[Some(9), Some(60), None, Some(60)]), - [2_950, 5_950, 2_950] + [2_380, 4_810, 2_380] ); assert_eq!( shares_at_moves(&[Some(9), Some(5), Some(30)]), - // from move 5, 25 of 55 moves on: 60000 * 55 / 850 - 50 - [2_950, 2_950, 3_832] + // from move 5, 25 of 55 moves on: 81% of 60000 * 55 / 850, less 50 + [2_380, 2_380, 3_094] ); } @@ -1876,12 +1877,18 @@ go depth 3 let line = "go wtime 60000 btime 4000"; assert_eq!( asked_of_engine_as(line, Color::White).limits.clock(), - Some(Clock::Share(Duration::from_millis(2950))), + Some(Clock::Share { + share: Duration::from_millis(2380), + limit: Duration::from_millis(7140), + }), "white was not given its own clock" ); assert_eq!( asked_of_engine_as(line, Color::Black).limits.clock(), - Some(Clock::Share(Duration::from_millis(150))), + Some(Clock::Share { + share: Duration::from_millis(112), + limit: Duration::from_millis(336), + }), "black was not given its own clock" ); }