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" ); }