When exact methods fail, approximations are too loose, and parameterized algorithms do not apply, the last layer is heuristic search. Simulated annealing accepts worsening moves with temperature-dependent probability, cooling toward greedy. Tabu search maintains a memory of recently visited solutions to force exploration. Genetic algorithms evolve populations. Large neighborhood search alternates destruction and repair. None come with worst-case quality guarantees. All routinely produce strong solutions on real instances when nothing else fits. Modern vehicle-routing solvers are heuristic-based. Modern scheduling SaaS like OptaPlanner combine heuristics with constraint solvers. Rust's argmin library provides a framework for several gradient-free methods. At this layer of the stack, you are doing engineering, not theory. Measure on representative instances. Tune the parameters. Accept that the work is empirical.
36
When everything else fails — heuristics
The last layer: search frameworks with no quality bounds, frequently the most useful at production scale.
- Simulated annealing in plain Rust — pick a neighbor, accept improvements always, accept regressions with temperature-dependent probability, cool gradually. For a real codebase reach for
argminto get logging, restarts, and the Metropolis criterion already wired up.use rand::Rng; fn anneal<S: Clone>( mut state: S, cost: impl Fn(&S) -> f64, neighbor: impl Fn(&S, &mut dyn rand::RngCore) -> S, mut temp: f64, cooling: f64, iterations: usize, ) -> S { let mut rng = rand::thread_rng(); let mut best = state.clone(); let mut best_cost = cost(&best); for _ in 0..iterations { let candidate = neighbor(&state, &mut rng); let delta = cost(&candidate) - cost(&state); if delta < 0.0 || rng.gen::<f64>() < (-delta / temp).exp() { state = candidate; let c = cost(&state); if c < best_cost { best_cost = c; best = state.clone(); } } temp *= cooling; } best } -
cost: impl Fn(&S) -> f64declares a parameter that accepts any value implementing the traitFn(&S) -> f64— any function or closure that takes&Sand returnsf64. This isimpl Traitin argument position, syntactic sugar for a generic with a trait bound (<F: Fn(&S) -> f64>). Rust has three closely-related function traits:Fn(...) -> Rcan be called any number of times and only borrows its captures (the most permissive);FnMut(...) -> Rcan be called any number of times but is allowed to mutate captures;FnOnce(...) -> Rcan be called exactly once and is allowed to move out of captures (the most restrictive caller). The compiler picks the loosest trait each closure can satisfy. Pick the loosest trait your callee actually needs —Fnhere, because the cost function is called many times and reads but does not change its captures. Callers can then pass a regularfnitem, a closure, or a function pointer interchangeably.// Three function traits — pick the loosest one your code actually needs. // // Fn(args) -> R can be called any number of times. // only borrows its captures. most permissive caller. // // FnMut(args) -> R can be called any number of times. // can MUTATE its captures. caller needs &mut access. // // FnOnce(args) -> R can be called exactly once. // can MOVE out of captures. most restrictive caller. // Page 36's signature: fn anneal<S: Clone>( /* ... */ cost: impl Fn(&S) -> f64, // called many times neighbor: impl Fn(&S, &mut dyn rand::RngCore) -> S, // also Fn-compatible /* ... */ ) -> S { /* ... */ } // Caller side — any of these work: // 1. A free function. fn distance(s: &Point) -> f64 { (s.x * s.x + s.y * s.y).sqrt() } anneal(/* ... */, distance, /* ... */); // 2. A closure capturing nothing. anneal(/* ... */, |s: &Point| s.x.abs() + s.y.abs(), /* ... */); // 3. A closure capturing a value by reference (Fn-compatible — borrows only). let target = 10.0; anneal(/* ... */, |s: &Point| (s.x - target).abs(), /* ... */); // 4. A closure that MUTATES — needs FnMut, not Fn. Page 36's Fn bound rejects: let mut call_count = 0; anneal(/* ... */, |s: &Point| { call_count += 1; s.x }, /* ... */); // ───────────── error: would need FnMut // impl Trait vs explicit generic — same meaning, different syntax: fn anneal_v1<S, F: Fn(&S) -> f64>(state: S, cost: F) -> f64 { cost(&state) } fn anneal_v2<S>(state: S, cost: impl Fn(&S) -> f64) -> f64 { cost(&state) } // impl Trait in argument position is sugar for an anonymous generic // parameter. More concise; the explicit form is sometimes preferred // when you want to refer to the type name elsewhere (e.g. with a turbofish). -
<S: Clone>declaresSas a generic type parameter with a trait bound:Scan be any type, as long as it implements theClonetrait. Trait bounds are how Rust expresses "any type T that supports these specific operations." Without the bound the function would not compile, becauselet mut best = state.clone();callsClone::clone()and the compiler will not let you call a method you have not proven the type supports. Annealing needsClonebecause tracking "best state seen so far" means duplicating the state at the moment it became the best — you cannot keep both the current and best state without duplication. Trait bounds compose with+:<S: Clone + Send>requires both Clone and Send. For complex bounds, awhereclause keeps the function signature readable.// Generic with a single trait bound — S is any type that implements Clone. fn anneal<S: Clone>( mut state: S, /* ... */ ) -> S { let mut best = state.clone(); // .clone() requires S: Clone /* ... */ best } // Without the Clone bound: fn anneal_broken<S>(state: S) -> S { let best = state.clone(); // error: no method `clone` found best // (S has no traits to call) } // Compose bounds with +: fn save<S: Clone + std::fmt::Debug>(s: &S) { println!("saving {:?}", s); // requires Debug let _backup = s.clone(); // requires Clone } // where clauses — same meaning, more readable for many or long bounds: fn anneal_wide<S>( state: S, cost: impl Fn(&S) -> f64, ) -> S where S: Clone + Send + 'static, { /* ... */ } // Common bounds and what they enable: // Clone .clone() — make a duplicate // Copy implicit by-value copy (page 15) // Debug {:?} formatting // Display {} formatting // PartialEq, Eq == comparisons // Hash use as HashMap or HashSet key // Send safe to move to another thread // Sync safe to share between threads // Iterator be a source for for-loops and combinators // // Add a bound only for the operations you actually need. Asking for // more than necessary forces callers to implement traits they don't need. -
temp *= coolingis a compound assignment — equivalent totemp = temp * cooling. Rust has the standard family from C / C++ / Python / JavaScript:+=,-=,*=,/=,%=, plus the bitwise variants&=,|=,^=,<<=,>>=. On page 36 this line gradually reduces the temperature each iteration — withcooling = 0.99, the temperature drops to about 36% of its starting value after 100 iterations and to about 0.7% after 500. The acceptance probability(-delta / temp).exp()depends ontemp, so as the schedule cools, the algorithm becomes more selective: high temp accepts almost any worsening move; low temp behaves like greedy descent. The smooth interpolation between "explore" and "exploit" is the whole point of simulated annealing.// temp *= cooling ≡ temp = temp * cooling let mut temp: f64 = 100.0; let cooling: f64 = 0.99; for _ in 0..100 { temp *= cooling; } // temp ≈ 36.6 for _ in 0..400 { temp *= cooling; } // temp ≈ 0.66 // The full family of compound assignment operators: // // += -= *= /= %= arithmetic // &= |= ^= bitwise AND / OR / XOR // <<= >>= bitwise shift // Why simulated annealing uses geometric cooling: // // The acceptance probability for a worsening move is exp(-delta / temp). // // high temp → exp(small magnitude) ≈ 1 ← accepts almost any move // low temp → exp(large magnitude) ≈ 0 ← accepts only improvements // // Geometric cooling (temp *= cooling) gives smooth interpolation between // "explore" and "exploit" — early iterations bounce around the solution // landscape, late iterations settle into a local minimum. // // Linear, logarithmic, and adaptive schedules exist; geometric is the // canonical default and tunes with one parameter. // One detail to note: compound assignment is a single operation, not two. // Rust's borrow checker can treat `x *= y` differently from `x = x * y` // for operator-overloaded types (implementing AddAssign separately from // Add can avoid an intermediate value). For plain numbers there's no // observable difference.