diff --git a/arche-core/src/board.rs b/arche-core/src/board.rs index 86486181..dd06f425 100644 --- a/arche-core/src/board.rs +++ b/arche-core/src/board.rs @@ -1449,7 +1449,7 @@ impl Board { /// The move number, as the fen prints it: `from_fen` starts the ply at /// twice the number it read, one more with black to move, so the number /// is the ply halved and needs no keeping. - fn move_number(&self) -> usize { + pub(crate) fn move_number(&self) -> usize { self.ply / 2 } diff --git a/arche-core/src/engine.rs b/arche-core/src/engine.rs index 37ce7005..30a7a3ae 100644 --- a/arche-core/src/engine.rs +++ b/arche-core/src/engine.rs @@ -411,6 +411,9 @@ pub trait Engine { fn active_color(&self) -> Color; + /// The move number the position's fen would print. + fn move_number(&self) -> u64; + /// Search each depth in turn until one is the last to finish. Every /// completed iteration is reported through `on_depth`. A result's node /// count covers the whole deepening so far, as the uci info convention @@ -3236,6 +3239,10 @@ impl Engine for AlphaBeta { self.board.active_color } + fn move_number(&self) -> u64 { + self.board.move_number() as u64 + } + fn parse_fen(&mut self, fen_string: &str) -> Result<(), String> { self.nodes = 0; self.board = Board::from_fen(fen_string)?; diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c751579a..fa7554ba 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -572,8 +572,8 @@ of these again without saying what is different this time. in the wrong place. Both arms moved a flat line. Since 621ecab the line moves between 30% and 60% with the chosen move's share of the root's nodes, goes to 65% when the move changes, and stays at 45% only where the root says nothing, so neither test resumes onto it; their pairs (65%: 77,236,380,242,65) are - against a baseline that no longer exists. `ASSUMED_MOVES_TO_GO` was measured with the flat - 45% in place. + against a baseline that no longer exists. The twentieth horizon was measured with the flat + 45% in place; the horizon now falls from 20 to 10 by move 60. - Declining the reverse futility cuts the pair term marks as riskiest. On 4.0 million fresh cuts from `arche residuals`, a model reading the pair term put 1,078 of 5,674 crossings in its riskiest 5%, against 929 for depth, phase and slack alone, +149 (95% +92 to +232). diff --git a/src/time_control.rs b/src/time_control.rs index bb6f3d11..d3bac270 100644 --- a/src/time_control.rs +++ b/src/time_control.rs @@ -10,14 +10,27 @@ use std::time::Duration; /// budget. Fifty covers a local interface; a network costs more. pub const DEFAULT_MOVE_OVERHEAD_MS: u64 = 50; -/// Moves we plan for when the time control does not say how many are left. +/// Moves we plan for when the time control does not say how many are left, +/// at the first move searched in a game. /// /// 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 /// at the end rather than how fast it runs out. At a fortieth, two self play /// games at 10+0.1 ended with about half the clock banked; a twentieth halves -/// the floor, to about 1.9 seconds (7913356). -const ASSUMED_MOVES_TO_GO: u64 = 20; +/// the floor, to about 1.9 seconds (7913356). At a twentieth throughout, +/// games at 10+0.1 still ended with about 3.5 seconds of the 10 left, so the +/// horizon falls as the game goes on and spends that on the middle and late +/// game: a re-run of 500 games' clocks with it ends them 1.27 seconds lower, +/// with 1.10 of the time and no clock under 750 ms. +const OPENING_HORIZON: u64 = 20; + +/// The fewest moves the horizon falls to. +const LATE_HORIZON: u64 = 10; + +/// The move number by which the horizon has fallen to `LATE_HORIZON`, in a +/// straight line from the first move searched. A game first searched at or +/// past it falls to `LATE_HORIZON` on the move after. +const LATE_MOVE: u64 = 60; /// Share of the increment we count on. It is credited only after the move, /// so spending all of it leaves nothing to cover the overhead. @@ -30,6 +43,28 @@ const MAX_CLOCK_PERCENT: u64 = 33; /// Searched even on a spent clock, so that a legal move comes back. const MIN_BUDGET_MS: u64 = 1; +/// Where a game stands for the horizon: the move number of the first +/// position searched on a clock in it, and of this one. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct GameMoves { + pub first: u64, + pub now: u64, +} + +impl GameMoves { + /// The clock over the horizon, which falls from `OPENING_HORIZON` at + /// the first move to `LATE_HORIZON` at `LATE_MOVE` and stays there. + /// The horizon times the span is kept whole, so the share is exact. + fn share_of(&self, time: u64) -> u64 { + let span = LATE_MOVE.saturating_sub(self.first).max(1); + let gone = self.now.saturating_sub(self.first); + let horizon_by_span = (OPENING_HORIZON * span) + .saturating_sub((OPENING_HORIZON - LATE_HORIZON).saturating_mul(gone)) + .max(LATE_HORIZON * span); + (time as u128 * span as u128 / horizon_by_span as u128) as u64 + } +} + /// The time part of a `go` command for the side to move, in milliseconds. #[derive(Debug, Default, PartialEq, Eq)] pub struct TimeControl { @@ -38,6 +73,8 @@ pub struct TimeControl { pub moves_to_go: Option, pub move_time: Option, pub infinite: bool, + /// Not on the line: the interface sets it from the game. + pub game: GameMoves, } impl TimeControl { @@ -55,6 +92,7 @@ impl TimeControl { moves_to_go: params.count("movestogo").read(), move_time: params.count("movetime").read(), infinite: params.flag("infinite"), + game: GameMoves::default(), } } @@ -82,11 +120,11 @@ impl TimeControl { fn clock_share(&self, time: u64, increment: u64) -> u64 { // the protocol does not define a count of zero, so it is no answer // rather than "this is the last move" - let moves_to_go = self - .moves_to_go - .filter(|&moves| moves > 0) - .unwrap_or(ASSUMED_MOVES_TO_GO); - let share = (time / moves_to_go).saturating_add(percent(increment, INCREMENT_PERCENT)); + let clock_part = match self.moves_to_go.filter(|&moves| moves > 0) { + Some(moves) => time / moves, + None => self.game.share_of(time), + }; + let share = clock_part.saturating_add(percent(increment, INCREMENT_PERCENT)); share.min(percent(time, MAX_CLOCK_PERCENT)) } } @@ -205,11 +243,44 @@ mod tests { } #[test] - fn sudden_death_plans_for_twenty_more_moves() { + fn sudden_death_plans_for_twenty_more_moves_at_the_first_move() { // 60000 / 20 - 50 assert_eq!(millis(&clock(60_000)), Some(2_950)); } + fn at_move(first: u64, now: u64) -> TimeControl { + TimeControl { + game: GameMoves { first, now }, + ..clock(60_000) + } + } + + #[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)); + } + + #[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)); + } + + #[test] + fn moves_to_go_is_read_whatever_the_move() { + let control = TimeControl { + moves_to_go: Some(10), + ..at_move(9, 9) + }; + assert_eq!(millis(&control), Some(5_950)); + } + #[test] fn moves_to_go_divides_the_clock() { let control = TimeControl { diff --git a/src/uci.rs b/src/uci.rs index 061b78fa..04bd8a4c 100644 --- a/src/uci.rs +++ b/src/uci.rs @@ -4,7 +4,7 @@ use crate::command::{Command, Keyword}; use crate::params::{Param, Params}; use crate::session::{self, SessionControl, SharedWriter, first_word, report_panics_to}; -use crate::time_control::{DEFAULT_MOVE_OVERHEAD_MS, TimeControl}; +use crate::time_control::{DEFAULT_MOVE_OVERHEAD_MS, GameMoves, TimeControl}; use arche_core::{ Color, Engine, Limits, Play, Score, ScoreBound, SearchConfig, SearchOutcome, SearchParameters, SearchResult, bench, @@ -177,6 +177,10 @@ pub struct UCI { /// with the null move rather than searched on the board left behind. position_refused: bool, + /// The move number of the first position searched on a clock since + /// `ucinewgame`, where the time horizon starts to fall. + first_move_on_clock: Option, + engine: T, out: SharedWriter, } @@ -220,6 +224,7 @@ impl UCI { Self { move_overhead: DEFAULT_MOVE_OVERHEAD_MS, position_refused: false, + first_move_on_clock: None, engine, out, } @@ -236,7 +241,10 @@ impl UCI { "isready" => self.say(format_args!("readyok")), // forgets the table and leaves the position to the `position` // that follows - "ucinewgame" => self.engine.new_game(), + "ucinewgame" => { + self.first_move_on_clock = None; + self.engine.new_game(); + } "uci" => { self.say(format_args!( "id name {} {}", @@ -503,15 +511,27 @@ impl UCI { } /// What a `go` asks for, or why it cannot be searched. - fn read_go(&self, line: &str) -> Result { + fn read_go(&mut self, line: &str) -> Result { if self.position_refused { return Err("no position to search: the last one sent was refused".to_string()); } - Go::of( + let mut go = Go::of( &Params::of(line), self.engine.active_color(), self.move_overhead, - ) + )?; + if go.time.time.is_some() { + let now = self.engine.move_number(); + // an earlier move than the first is a new game that came without + // a ucinewgame, or a takeback, and either starts the count again + let first = self + .first_move_on_clock + .filter(|&first| first <= now) + .unwrap_or(now); + self.first_move_on_clock = Some(first); + go.time.game = GameMoves { first, now }; + } + Ok(go) } /// The last info line, said for the whole search rather than an iteration. @@ -858,6 +878,7 @@ mod tests { struct Recorder { asked: Option, color: Color, + move_number: u64, cleared: usize, } @@ -866,6 +887,7 @@ mod tests { Self { asked: None, color, + move_number: 1, cleared: 0, } } @@ -885,6 +907,10 @@ mod tests { self.color } + fn move_number(&self) -> u64 { + self.move_number + } + fn parse_fen(&mut self, _fen: &str) -> Result<(), String> { Ok(()) } @@ -1370,6 +1396,51 @@ go depth 3 ); } + /// The share a `go wtime 60000` asks for at each move number in turn, + /// with a `ucinewgame` where the list says `None`. + fn shares_at_moves(moves: &[Option]) -> Vec { + let mut uci = recording(); + let mut shares = Vec::new(); + for &number in moves { + match number { + None => { + uci.handle("ucinewgame"); + } + Some(number) => { + 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 + } + + #[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]); + // a move time does not start the game's horizon + let mut uci = recording(); + uci.engine.move_number = 50; + uci.handle("go movetime 500"); + assert_eq!(uci.first_move_on_clock, None); + } + + #[test] + 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] + ); + 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] + ); + } + #[test] fn the_move_overhead_the_option_sets_is_held_back_from_the_budget() { for (overhead, budget) in [(0, 500), (50, 450), (200, 300)] { @@ -2085,6 +2156,9 @@ go depth 3 fn active_color(&self) -> Color { Color::White } + fn move_number(&self) -> u64 { + 1 + } fn parse_fen(&mut self, _fen: &str) -> Result<(), String> { Ok(()) }