diff --git a/Cargo.toml b/Cargo.toml index 1ef274c..d3146d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ name = "pathwise" version = "0.2.0" edition = "2024" +rust-version = "1.88" description = "Generic search, optimization, and constraint-solving building blocks for Rust" license = "MIT" repository = "https://github.com/casoon/pathwise" diff --git a/README.md b/README.md index 47998ae..765f722 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,9 @@ a problem's state space: - **search** — BFS, DFS, iterative deepening, uniform cost search, best-first search, A*, beam search - **optimization** — branch and bound, hill climbing, local search, - simulated annealing + simulated annealing, and budgeted local search for problems whose + evaluation is expensive, asynchronous and noisy (`AsyncOptimizationProblem`, + e.g. tuning a language-model program against a dataset) - **constraint** — backtracking, forward checking, constraint propagation (AC-3), variable/value ordering heuristics - **graph** — topological sort, with matching/flow/coloring evaluated diff --git a/src/core/mod.rs b/src/core/mod.rs index fb09c26..72af7ee 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -3,6 +3,7 @@ pub mod cancellation; pub mod incumbent; pub mod problem; +pub(crate) mod rng; pub mod solution; pub mod strategy; pub mod termination; diff --git a/src/core/rng.rs b/src/core/rng.rs new file mode 100644 index 0000000..d78edfc --- /dev/null +++ b/src/core/rng.rs @@ -0,0 +1,36 @@ +//! Deterministic pseudo-random numbers for reproducible randomized algorithms. + +/// Linear congruential generator (Knuth's MMIX constants). Not for cryptography; chosen +/// so that a seed reproduces a run exactly without depending on an RNG crate. +#[derive(Debug, Clone)] +pub(crate) struct LcgRng { + state: u64, +} + +impl LcgRng { + pub(crate) fn new(seed: u64) -> Self { + Self { state: seed } + } + + /// Uniform in `[0, 1)`. + pub(crate) fn next_f64(&mut self) -> f64 { + self.state = self + .state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + let val = (self.state >> 11) as f64; + val / (1u64 << 53) as f64 + } + + /// Uniform in `0..n`; `n` must be greater than zero. + pub(crate) fn below(&mut self, n: usize) -> usize { + ((self.next_f64() * n as f64) as usize).min(n - 1) + } + + /// Fisher–Yates shuffle. + pub(crate) fn shuffle(&mut self, items: &mut [T]) { + for i in (1..items.len()).rev() { + items.swap(i, self.below(i + 1)); + } + } +} diff --git a/src/optimization/budgeted.rs b/src/optimization/budgeted.rs new file mode 100644 index 0000000..6ecb025 --- /dev/null +++ b/src/optimization/budgeted.rs @@ -0,0 +1,178 @@ +//! Local search for problems whose evaluation is expensive, asynchronous and noisy. +//! +//! The other optimizers in this module assume a cheap, deterministic `score` and spend +//! evaluations freely. When every evaluation is a batch of network calls — tuning a +//! language-model program on a dataset, a simulation run, a benchmark — the evaluation +//! count is the budget, an evaluation must be awaitable, and "better" may need a test +//! rather than a comparison of two numbers. + +use std::collections::HashMap; +use std::future::Future; +use std::hash::Hash; + +use crate::core::problem::Problem; +use crate::core::rng::LcgRng; +use crate::core::solution::SearchMetrics; + +/// An optimization problem with an expensive, possibly asynchronous and noisy evaluation. +/// +/// Unlike [`OptimizationProblem`](crate::core::problem::OptimizationProblem), the score +/// does not have to be totally ordered: [`is_improvement`](Self::is_improvement) decides +/// whether a candidate beats the incumbent, so a noisy score can carry the per-sample +/// results a statistical test needs. +pub trait AsyncOptimizationProblem: Problem { + /// Result of evaluating a state. + type Score: Clone; + + /// Evaluates `state`. Called at most once per distinct state by + /// [`budgeted_local_search`]. + fn evaluate(&self, state: &Self::State) -> impl Future; + + /// Whether `candidate` is better than `incumbent` by enough to move to it. + fn is_improvement(&self, candidate: &Self::Score, incumbent: &Self::Score) -> bool; +} + +/// Options for [`budgeted_local_search`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BudgetedSearchOptions { + /// Maximum number of evaluations, including the initial state. + pub max_evaluations: usize, + /// Seed for the order in which neighbors are tried. + pub seed: u64, +} + +impl Default for BudgetedSearchOptions { + fn default() -> Self { + Self { + max_evaluations: 20, + seed: 0, + } + } +} + +/// One evaluation made during the search, in order. +#[derive(Debug, Clone, PartialEq)] +pub struct Evaluation { + pub state: State, + pub score: Score, + /// Whether the search moved to this state. + pub accepted: bool, +} + +/// Outcome of [`budgeted_local_search`]. +#[derive(Debug, Clone, PartialEq)] +pub struct BudgetedSolution { + /// Best state found. + pub state: State, + pub score: Score, + /// Every evaluation, in order; the first is the initial state. + pub evaluations: Vec>, + /// `true` when no neighbor of the final state improves on it, `false` when the budget + /// ran out first. + pub local_optimum: bool, + pub metrics: SearchMetrics, +} + +/// First-improvement local search under a budget of evaluations. +/// +/// Starts at [`Problem::initial`], tries the neighbors of the current state in a seeded +/// random order, and moves to the first one that [`is_improvement`] accepts. Stops at a +/// local optimum or when the budget is spent. No state is evaluated twice. +/// +/// First improvement rather than steepest ascent: with expensive evaluations, scoring +/// every neighbor before moving wastes most of the budget. +/// +/// # Complexity +/// +/// - **Evaluations**: at most `options.max_evaluations`, each awaited once. +/// - **Time**: $O(E \cdot b)$ besides evaluation, where $E$ is the number of evaluations and +/// $b$ the neighborhood size (generating and shuffling moves). +/// - **Space**: $O(E)$ for the evaluation log and the cache of evaluated states. +/// +/// # Requirements +/// +/// - `P::State: Eq + Hash` for the cache of evaluated states. +/// - The search is sequential: one evaluation at a time. Parallelism belongs inside +/// [`AsyncOptimizationProblem::evaluate`] (e.g. scoring a dataset concurrently). +/// - Runtime-agnostic: the returned future can be awaited on any executor. +/// +/// # Prefer this when +/// +/// - Each evaluation is costly enough that its count, not the search logic, is the budget. +/// - Scores are noisy and acceptance needs more than `>`. +/// +/// # Consider instead +/// +/// - [`hill_climbing`](crate::optimization::hill_climbing()) or +/// [`local_search`](crate::optimization::local_search()) when scoring is cheap. +/// +/// # References +/// +/// - Russell, S., & Norvig, P. (2020). *Artificial Intelligence: A Modern Approach* (4th ed.). +/// Pearson. Chapter 4.1.1 (first-choice hill climbing). +/// +/// [`is_improvement`]: AsyncOptimizationProblem::is_improvement +pub async fn budgeted_local_search

( + problem: &P, + options: BudgetedSearchOptions, +) -> BudgetedSolution +where + P: AsyncOptimizationProblem, + P::State: Eq + Hash, +{ + let mut rng = LcgRng::new(options.seed); + let mut evaluated: HashMap = HashMap::new(); + let mut evaluations = Vec::new(); + let mut metrics = SearchMetrics::default(); + + let mut current = problem.initial(); + let mut current_score = problem.evaluate(¤t).await; + evaluated.insert(current.clone(), current_score.clone()); + evaluations.push(Evaluation { + state: current.clone(), + score: current_score.clone(), + accepted: true, + }); + metrics.nodes_visited = 1; + + let mut local_optimum = false; + 'search: while evaluations.len() < options.max_evaluations { + metrics.nodes_expanded += 1; + let mut moves: Vec = problem.moves(¤t).collect(); + rng.shuffle(&mut moves); + + for mv in &moves { + let neighbor = problem.apply(¤t, mv); + metrics.nodes_visited += 1; + if evaluated.contains_key(&neighbor) { + continue; + } + if evaluations.len() >= options.max_evaluations { + break 'search; + } + let score = problem.evaluate(&neighbor).await; + evaluated.insert(neighbor.clone(), score.clone()); + let accepted = problem.is_improvement(&score, ¤t_score); + evaluations.push(Evaluation { + state: neighbor.clone(), + score: score.clone(), + accepted, + }); + if accepted { + current = neighbor; + current_score = score; + continue 'search; + } + } + local_optimum = true; + break; + } + + BudgetedSolution { + state: current, + score: current_score, + evaluations, + local_optimum, + metrics, + } +} diff --git a/src/optimization/mod.rs b/src/optimization/mod.rs index 81dfabb..5a3e53b 100644 --- a/src/optimization/mod.rs +++ b/src/optimization/mod.rs @@ -1,8 +1,9 @@ //! Optimization algorithms module. //! -//! Includes Branch & Bound exact optimization, Hill Climbing, Local Search, Simulated Annealing, Tabu Search, and Large Neighborhood Search. +//! Includes Branch & Bound exact optimization, Hill Climbing, Local Search, Simulated Annealing, Tabu Search, Large Neighborhood Search, and budgeted local search for expensive, asynchronous evaluations. pub mod branch_and_bound; +pub mod budgeted; pub mod coordination; pub mod hill_climbing; pub mod lns; @@ -11,6 +12,10 @@ pub mod simulated_annealing; pub mod tabu_search; pub use branch_and_bound::{BranchAndBoundProblem, branch_and_bound}; +pub use budgeted::{ + AsyncOptimizationProblem, BudgetedSearchOptions, BudgetedSolution, Evaluation, + budgeted_local_search, +}; pub use coordination::Coordination; pub use hill_climbing::{HillClimbingOptions, hill_climbing}; pub use lns::{LnsOptions, LnsProblem, large_neighborhood_search}; diff --git a/src/optimization/simulated_annealing.rs b/src/optimization/simulated_annealing.rs index 006ac21..0e83bb1 100644 --- a/src/optimization/simulated_annealing.rs +++ b/src/optimization/simulated_annealing.rs @@ -1,6 +1,7 @@ //! Simulated Annealing Optimization. use crate::core::problem::OptimizationProblem; +use crate::core::rng::LcgRng; use crate::core::solution::{SearchMetrics, Solution}; /// Options for Simulated Annealing schedule. @@ -27,26 +28,6 @@ impl Default for SimulatedAnnealingOptions { } } -/// Simple deterministic linear congruential PRNG for reproducible annealing choices. -struct LcgRng { - state: u64, -} - -impl LcgRng { - fn new(seed: u64) -> Self { - Self { state: seed } - } - - fn next_f64(&mut self) -> f64 { - self.state = self - .state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - let val = (self.state >> 11) as f64; - val / (1u64 << 53) as f64 - } -} - /// Optimizes state solutions using Simulated Annealing. /// /// Simulated Annealing avoids local optima by accepting worsening moves with probability diff --git a/tests/budgeted_tests.rs b/tests/budgeted_tests.rs new file mode 100644 index 0000000..5d5ce7e --- /dev/null +++ b/tests/budgeted_tests.rs @@ -0,0 +1,171 @@ +use std::cell::Cell; +use std::collections::HashSet; +use std::future::Future; +use std::pin::pin; +use std::task::{Context, Poll, Waker}; + +use pathwise::Problem; +use pathwise::optimization::{ + AsyncOptimizationProblem, BudgetedSearchOptions, budgeted_local_search, +}; + +/// Minimal executor: the futures in these tests never wait. +fn block_on(future: F) -> F::Output { + let mut future = pin!(future); + let mut context = Context::from_waker(Waker::noop()); + loop { + if let Poll::Ready(output) = future.as_mut().poll(&mut context) { + return output; + } + } +} + +/// Choose 3 of 8 items; an item's value is its index, the score is the sum. +/// Evaluations are counted to check the budget and the cache. +struct PickThree { + calls: Cell, + /// Required gain before a move counts as an improvement. + margin: u32, +} + +impl Problem for PickThree { + type State = Vec; + type Move = (usize, u32); + + fn initial(&self) -> Vec { + vec![0, 1, 2] + } + + fn moves(&self, state: &Vec) -> impl Iterator { + let chosen: HashSet = state.iter().copied().collect(); + let unchosen: Vec = (0..8).filter(|i| !chosen.contains(i)).collect(); + (0..state.len()) + .flat_map(move |slot| unchosen.clone().into_iter().map(move |item| (slot, item))) + } + + fn apply(&self, state: &Vec, &(slot, item): &(usize, u32)) -> Vec { + let mut next = state.clone(); + next[slot] = item; + next.sort(); + next + } + + fn is_goal(&self, _: &Vec) -> bool { + false + } +} + +impl AsyncOptimizationProblem for PickThree { + type Score = u32; + + async fn evaluate(&self, state: &Vec) -> u32 { + self.calls.set(self.calls.get() + 1); + state.iter().sum() + } + + fn is_improvement(&self, candidate: &u32, incumbent: &u32) -> bool { + *candidate > incumbent + self.margin + } +} + +fn problem(margin: u32) -> PickThree { + PickThree { + calls: Cell::new(0), + margin, + } +} + +#[test] +fn reaches_the_optimum_with_enough_budget() { + let p = problem(0); + let options = BudgetedSearchOptions { + max_evaluations: 200, + seed: 7, + }; + let solution = block_on(budgeted_local_search(&p, options)); + assert_eq!(solution.state, vec![5, 6, 7]); + assert_eq!(solution.score, 18); + assert!(solution.local_optimum); + assert_eq!(p.calls.get(), solution.evaluations.len()); + assert!( + solution.evaluations[0].accepted, + "the initial state is the first evaluation" + ); +} + +#[test] +fn respects_the_budget() { + let p = problem(0); + let options = BudgetedSearchOptions { + max_evaluations: 5, + seed: 1, + }; + let solution = block_on(budgeted_local_search(&p, options)); + assert_eq!(p.calls.get(), 5); + assert_eq!(solution.evaluations.len(), 5); + assert!(!solution.local_optimum); + let best = solution + .evaluations + .iter() + .filter(|e| e.accepted) + .map(|e| e.score) + .max() + .unwrap(); + assert_eq!( + solution.score, best, + "the result is the last accepted state" + ); +} + +#[test] +fn never_evaluates_a_state_twice() { + let p = problem(0); + let solution = block_on(budgeted_local_search( + &p, + BudgetedSearchOptions { + max_evaluations: 200, + seed: 3, + }, + )); + let distinct: HashSet<&Vec> = solution.evaluations.iter().map(|e| &e.state).collect(); + assert_eq!(distinct.len(), solution.evaluations.len()); +} + +#[test] +fn custom_acceptance_stops_early() { + // Single swaps gain at most 7; a margin of 7 rejects every move. + let p = problem(7); + let solution = block_on(budgeted_local_search( + &p, + BudgetedSearchOptions { + max_evaluations: 200, + seed: 0, + }, + )); + assert_eq!(solution.state, vec![0, 1, 2]); + assert!(solution.local_optimum); + assert_eq!( + solution.evaluations.iter().filter(|e| e.accepted).count(), + 1 + ); +} + +#[test] +fn same_seed_same_run() { + let run = |seed| { + let p = problem(0); + block_on(budgeted_local_search( + &p, + BudgetedSearchOptions { + max_evaluations: 12, + seed, + }, + )) + .evaluations + .into_iter() + .map(|e| e.state) + .collect::>() + }; + assert_eq!(run(42), run(42)); + assert_ne!(run(42), run(43)); +}