Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion arche-core/src/board.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
7 changes: 7 additions & 0 deletions arche-core/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)?;
Expand Down
4 changes: 2 additions & 2 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
89 changes: 80 additions & 9 deletions src/time_control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -38,6 +73,8 @@ pub struct TimeControl {
pub moves_to_go: Option<u64>,
pub move_time: Option<u64>,
pub infinite: bool,
/// Not on the line: the interface sets it from the game.
pub game: GameMoves,
}

impl TimeControl {
Expand All @@ -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(),
}
}

Expand Down Expand Up @@ -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))
}
}
Expand Down Expand Up @@ -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 {
Expand Down
84 changes: 79 additions & 5 deletions src/uci.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -177,6 +177,10 @@ pub struct UCI<T: Engine, W: Write> {
/// 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<u64>,

engine: T,
out: SharedWriter<W>,
}
Expand Down Expand Up @@ -220,6 +224,7 @@ impl<T: Engine, W: Write> UCI<T, W> {
Self {
move_overhead: DEFAULT_MOVE_OVERHEAD_MS,
position_refused: false,
first_move_on_clock: None,
engine,
out,
}
Expand All @@ -236,7 +241,10 @@ impl<T: Engine, W: Write> UCI<T, W> {
"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 {} {}",
Expand Down Expand Up @@ -503,15 +511,27 @@ impl<T: Engine, W: Write> UCI<T, W> {
}

/// What a `go` asks for, or why it cannot be searched.
fn read_go(&self, line: &str) -> Result<Go, String> {
fn read_go(&mut self, line: &str) -> Result<Go, String> {
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.
Expand Down Expand Up @@ -858,6 +878,7 @@ mod tests {
struct Recorder {
asked: Option<SearchParameters>,
color: Color,
move_number: u64,
cleared: usize,
}

Expand All @@ -866,6 +887,7 @@ mod tests {
Self {
asked: None,
color,
move_number: 1,
cleared: 0,
}
}
Expand All @@ -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(())
}
Expand Down Expand Up @@ -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<u64>]) -> Vec<u64> {
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)] {
Expand Down Expand Up @@ -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(())
}
Expand Down
Loading