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
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
36 changes: 36 additions & 0 deletions src/core/rng.rs
Original file line number Diff line number Diff line change
@@ -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<T>(&mut self, items: &mut [T]) {
for i in (1..items.len()).rev() {
items.swap(i, self.below(i + 1));
}
}
}
178 changes: 178 additions & 0 deletions src/optimization/budgeted.rs
Original file line number Diff line number Diff line change
@@ -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<Output = Self::Score>;

/// 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<State, Score> {
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<State, Score> {
/// Best state found.
pub state: State,
pub score: Score,
/// Every evaluation, in order; the first is the initial state.
pub evaluations: Vec<Evaluation<State, Score>>,
/// `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<P>(
problem: &P,
options: BudgetedSearchOptions,
) -> BudgetedSolution<P::State, P::Score>
where
P: AsyncOptimizationProblem,
P::State: Eq + Hash,
{
let mut rng = LcgRng::new(options.seed);
let mut evaluated: HashMap<P::State, P::Score> = HashMap::new();
let mut evaluations = Vec::new();
let mut metrics = SearchMetrics::default();

let mut current = problem.initial();
let mut current_score = problem.evaluate(&current).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<P::Move> = problem.moves(&current).collect();
rng.shuffle(&mut moves);

for mv in &moves {
let neighbor = problem.apply(&current, 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, &current_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,
}
}
7 changes: 6 additions & 1 deletion src/optimization/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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};
Expand Down
21 changes: 1 addition & 20 deletions src/optimization/simulated_annealing.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand Down
Loading
Loading