diff --git a/crates/solverforge-solver/src/heuristic/selector/list_kernel/nearby_change.rs b/crates/solverforge-solver/src/heuristic/selector/list_kernel/nearby_change.rs index 7450b91e..401fee7c 100644 --- a/crates/solverforge-solver/src/heuristic/selector/list_kernel/nearby_change.rs +++ b/crates/solverforge-solver/src/heuristic/selector/list_kernel/nearby_change.rs @@ -6,9 +6,7 @@ use crate::heuristic::selector::list_support::ordered_index; use crate::heuristic::selector::move_selector::{ CandidateId, CandidateStore, MoveCandidateRef, MoveCursor, MoveStreamContext, }; -use crate::heuristic::selector::nearby_list_support::{ - sort_and_limit_nearby_candidates, NearbyCandidate, -}; +use crate::heuristic::selector::nearby_list_support::BoundedNearbyCandidates; use crate::heuristic::selector::precedence_route::PrecedenceRouteGraph; use crate::list_placement::OwnerRestriction; @@ -34,11 +32,10 @@ where source_idx: usize, source_pos_offset: usize, current_source: (usize, usize), - destinations: Vec<(usize, usize)>, + destinations: BoundedNearbyCandidates, destination_offset: usize, fixed_to_current_entity: bool, precedence_route_graph: Option, - max_nearby: usize, descriptor_index: usize, source_salt: u64, } @@ -73,11 +70,10 @@ where source_idx: 0, source_pos_offset: 0, current_source: (0, 0), - destinations: Vec::new(), + destinations: BoundedNearbyCandidates::new(max_nearby), destination_offset: 0, fixed_to_current_entity, precedence_route_graph: None, - max_nearby, descriptor_index, source_salt, } @@ -102,8 +98,6 @@ where } fn load_next_source(&mut self, solution: &S) -> bool { - let mut candidates: Vec = Vec::new(); - while self.source_idx < self.entities.len() { let source_entity = self.entities[self.source_idx]; let source_len = self.route_lens[self.source_idx]; @@ -131,7 +125,7 @@ where continue; } - candidates.clear(); + self.destinations.clear(); let source_allowed = source_restriction.is_none_or(|restriction| restriction.allows(source_entity)); if source_allowed { @@ -159,7 +153,11 @@ where destination_position.min(source_len.saturating_sub(1)), ); if distance.is_finite() { - candidates.push((source_entity, destination_position, distance)); + self.destinations.offer(( + source_entity, + destination_position, + distance, + )); } } } @@ -187,7 +185,7 @@ where reference_position, ); if distance.is_finite() { - candidates.push(( + self.destinations.offer(( destination_entity, destination_position, distance, @@ -197,17 +195,10 @@ where } } - sort_and_limit_nearby_candidates(&mut candidates, self.max_nearby); - if candidates.is_empty() { + if self.destinations.is_empty() { continue; } self.current_source = (source_entity, source_position); - self.destinations.clear(); - self.destinations.extend( - candidates - .iter() - .map(|&(entity, position, _)| (entity, position)), - ); self.destination_offset = 0; return true; } @@ -224,7 +215,8 @@ where return None; } let (source_entity, source_position) = self.current_source; - let (destination_entity, destination_position) = self.destinations[self.destination_offset]; + let (destination_entity, destination_position) = + self.destinations.destination(self.destination_offset); self.destination_offset += 1; Some(self.emitter.emit_change( source_entity, diff --git a/crates/solverforge-solver/src/heuristic/selector/list_kernel/nearby_swap.rs b/crates/solverforge-solver/src/heuristic/selector/list_kernel/nearby_swap.rs index dae26f45..a4d32835 100644 --- a/crates/solverforge-solver/src/heuristic/selector/list_kernel/nearby_swap.rs +++ b/crates/solverforge-solver/src/heuristic/selector/list_kernel/nearby_swap.rs @@ -6,9 +6,7 @@ use crate::heuristic::selector::list_support::ordered_index; use crate::heuristic::selector::move_selector::{ CandidateId, CandidateStore, MoveCandidateRef, MoveCursor, MoveStreamContext, }; -use crate::heuristic::selector::nearby_list_support::{ - sort_and_limit_nearby_candidates, NearbyCandidate, -}; +use crate::heuristic::selector::nearby_list_support::BoundedNearbyCandidates; use crate::heuristic::selector::precedence_route::PrecedenceRouteGraph; use crate::list_placement::OwnerRestriction; @@ -34,11 +32,10 @@ where source_idx: usize, source_pos_offset: usize, current_source: (usize, usize), - destinations: Vec<(usize, usize)>, + destinations: BoundedNearbyCandidates, destination_offset: usize, fixed_to_current_entity: bool, precedence_route_graph: Option, - max_nearby: usize, descriptor_index: usize, source_salt: u64, } @@ -73,11 +70,10 @@ where source_idx: 0, source_pos_offset: 0, current_source: (0, 0), - destinations: Vec::new(), + destinations: BoundedNearbyCandidates::new(max_nearby), destination_offset: 0, fixed_to_current_entity, precedence_route_graph: None, - max_nearby, descriptor_index, source_salt, } @@ -102,7 +98,6 @@ where } fn load_next_source(&mut self, solution: &S) -> bool { - let mut candidates: Vec = Vec::new(); while self.source_idx < self.entities.len() { let source_entity = self.entities[self.source_idx]; let source_len = self.route_lens[self.source_idx]; @@ -129,7 +124,7 @@ where continue; } - candidates.clear(); + self.destinations.clear(); for destination_position in source_position + 1..source_len { let destination_restriction = self .probe @@ -163,7 +158,8 @@ where destination_position, ); if distance.is_finite() { - candidates.push((source_entity, destination_position, distance)); + self.destinations + .offer((source_entity, destination_position, distance)); } } @@ -206,7 +202,7 @@ where destination_position, ); if distance.is_finite() { - candidates.push(( + self.destinations.offer(( destination_entity, destination_position, distance, @@ -216,17 +212,10 @@ where } } - sort_and_limit_nearby_candidates(&mut candidates, self.max_nearby); - if candidates.is_empty() { + if self.destinations.is_empty() { continue; } self.current_source = (source_entity, source_position); - self.destinations.clear(); - self.destinations.extend( - candidates - .iter() - .map(|&(entity, position, _)| (entity, position)), - ); self.destination_offset = 0; return true; } @@ -243,7 +232,8 @@ where return None; } let (source_entity, source_position) = self.current_source; - let (destination_entity, destination_position) = self.destinations[self.destination_offset]; + let (destination_entity, destination_position) = + self.destinations.destination(self.destination_offset); self.destination_offset += 1; Some(self.emitter.emit_swap( source_entity, diff --git a/crates/solverforge-solver/src/heuristic/selector/nearby_list_support.rs b/crates/solverforge-solver/src/heuristic/selector/nearby_list_support.rs index 28c24a56..840f3f3d 100644 --- a/crates/solverforge-solver/src/heuristic/selector/nearby_list_support.rs +++ b/crates/solverforge-solver/src/heuristic/selector/nearby_list_support.rs @@ -1,74 +1,78 @@ +//! Bounded nearest-destination selection shared by the nearby list cursors. + +use std::cmp::Ordering; + +/// One nearby destination: `(entity, position, distance)`. pub(crate) type NearbyCandidate = (usize, usize, f64); -pub(crate) fn sort_and_limit_nearby_candidates( - candidates: &mut Vec, - max_nearby: usize, -) { - use std::cmp::Ordering; +/// The `limit` nearest destinations offered so far, in ascending distance. +/// +/// Destinations at equal distance keep the order they were offered in, so the +/// retained set is exactly what a stable sort by distance followed by a +/// truncation to `limit` would keep. The buffer never grows past `limit` and +/// keeps its capacity across [`Self::clear`], so a cursor reuses one buffer for +/// every source position instead of collecting every reachable destination. +#[derive(Debug)] +pub(crate) struct BoundedNearbyCandidates { + retained: Vec, + limit: usize, +} + +impl BoundedNearbyCandidates { + /// Creates an empty buffer that retains at most `limit` destinations. + /// + /// Capacity grows on demand to the number of destinations actually + /// retained, so an unbounded `limit` reserves nothing up front. + pub(crate) fn new(limit: usize) -> Self { + Self { + retained: Vec::new(), + limit, + } + } - if max_nearby == 0 { - candidates.clear(); - return; + /// Drops every retained destination while keeping the capacity. + pub(crate) fn clear(&mut self) { + self.retained.clear(); } - let mut retained = 0; - for read in 0..candidates.len() { - let candidate = candidates[read]; - let insertion = candidates[..retained].partition_point(|existing| { + /// Offers one destination, keeping it only if it ranks among the nearest + /// `limit` destinations offered since the last [`Self::clear`]. + #[inline] + pub(crate) fn offer(&mut self, candidate: NearbyCandidate) { + let insertion = self.retained.partition_point(|existing| { existing .2 .partial_cmp(&candidate.2) .unwrap_or(Ordering::Equal) != Ordering::Greater }); - if insertion >= max_nearby { - continue; + if insertion >= self.limit { + return; } - - let next_retained = (retained + 1).min(max_nearby); - candidates.copy_within(insertion..next_retained - 1, insertion + 1); - candidates[insertion] = candidate; - retained = next_retained; + if self.retained.len() == self.limit { + self.retained.pop(); + } + self.retained.insert(insertion, candidate); } - candidates.truncate(retained); -} - -#[cfg(test)] -mod tests { - use std::cmp::Ordering; - use super::{sort_and_limit_nearby_candidates, NearbyCandidate}; + /// Number of retained destinations. + pub(crate) fn len(&self) -> usize { + self.retained.len() + } - fn stable_sort_and_limit( - mut candidates: Vec, - max_nearby: usize, - ) -> Vec { - candidates.sort_by(|left, right| left.2.partial_cmp(&right.2).unwrap_or(Ordering::Equal)); - candidates.truncate(max_nearby); - candidates + /// Whether no destination is retained. + pub(crate) fn is_empty(&self) -> bool { + self.retained.is_empty() } - #[test] - fn bounded_order_matches_stable_full_sort() { - for len in 0_usize..96 { - let mut state = 0x9E37_79B9_u32 ^ len as u32; - let candidates = (0..len) - .map(|index| { - state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); - let distance = if index % 17 == 0 { - -0.0 - } else { - f64::from(state % 11) - }; - (index / 7, index, distance) - }) - .collect::>(); - for max_nearby in 0..=len + 2 { - let expected = stable_sort_and_limit(candidates.clone(), max_nearby); - let mut actual = candidates.clone(); - sort_and_limit_nearby_candidates(&mut actual, max_nearby); - assert_eq!(actual, expected, "len={len}, max_nearby={max_nearby}"); - } - } + /// The `(entity, position)` of the retained destination at `index`. + #[inline] + pub(crate) fn destination(&self, index: usize) -> (usize, usize) { + let (entity, position, _) = self.retained[index]; + (entity, position) } } + +#[cfg(test)] +#[path = "nearby_list_support_tests.rs"] +mod tests; diff --git a/crates/solverforge-solver/src/heuristic/selector/nearby_list_support_tests.rs b/crates/solverforge-solver/src/heuristic/selector/nearby_list_support_tests.rs new file mode 100644 index 00000000..8c725fdb --- /dev/null +++ b/crates/solverforge-solver/src/heuristic/selector/nearby_list_support_tests.rs @@ -0,0 +1,66 @@ +use std::cmp::Ordering; + +use super::{BoundedNearbyCandidates, NearbyCandidate}; + +fn stable_sort_and_limit( + mut candidates: Vec, + limit: usize, +) -> Vec { + candidates.sort_by(|left, right| left.2.partial_cmp(&right.2).unwrap_or(Ordering::Equal)); + candidates.truncate(limit); + candidates +} + +#[test] +fn bounded_order_matches_stable_full_sort() { + for len in 0_usize..96 { + let mut state = 0x9E37_79B9_u32 ^ len as u32; + let candidates = (0..len) + .map(|index| { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let distance = if index % 17 == 0 { + -0.0 + } else { + f64::from(state % 11) + }; + (index / 7, index, distance) + }) + .collect::>(); + for limit in 0..=len + 2 { + let expected = stable_sort_and_limit(candidates.clone(), limit); + let mut bounded = BoundedNearbyCandidates::new(limit); + for &candidate in &candidates { + bounded.offer(candidate); + } + assert_eq!(bounded.retained, expected, "len={len}, limit={limit}"); + assert_eq!(bounded.len(), expected.len()); + for (index, &(entity, position, _)) in expected.iter().enumerate() { + assert_eq!(bounded.destination(index), (entity, position)); + } + } + } +} + +#[test] +fn clear_reuses_capacity_without_growing_past_limit() { + let mut bounded = BoundedNearbyCandidates::new(4); + let mut capacity = None; + for round in 0..3 { + bounded.clear(); + assert!(bounded.is_empty()); + for index in 0..64 { + bounded.offer((round, index, f64::from((index * 37 % 64) as u32))); + } + assert_eq!(bounded.len(), 4); + let after_round = bounded.retained.capacity(); + assert!( + after_round <= 8, + "capacity {after_round} grew past the limit" + ); + assert_eq!(*capacity.get_or_insert(after_round), after_round); + } + assert_eq!( + bounded.retained, + [(2, 0, 0.0), (2, 45, 1.0), (2, 26, 2.0), (2, 7, 3.0)] + ); +}