diff --git a/crates/solverforge-config/WIREFRAME.md b/crates/solverforge-config/WIREFRAME.md index 32ce35f3..691d0ecb 100644 --- a/crates/solverforge-config/WIREFRAME.md +++ b/crates/solverforge-config/WIREFRAME.md @@ -138,7 +138,7 @@ Derives: `Debug, Clone, Default, Deserialize, Serialize`. | `construction_heuristic_type` | `ConstructionHeuristicType` | `FirstFit` | | `construction_obligation` | `ConstructionObligation` | `PreserveUnassigned` | | `target` | `VariableTargetConfig` | empty target | -| `k` | `usize` | `2` (for `ListKOpt`); valid: exactly `2` (the list k-opt construction kernel implements 2-opt only) | +| `k` | `usize` | `2`; valid: `2..=5` for `ListKOpt`, which polishes each route with k-opt; other heuristics do not use it and reject any value but `2` | | `value_candidate_limit` | `Option` | `None`; valid: `>= 1` when set | | `group_name` | `Option` | `None` | | `group_candidate_limit` | `Option` | `None`; valid: `>= 1` when set | diff --git a/crates/solverforge-config/src/phase.rs b/crates/solverforge-config/src/phase.rs index 58e6d8b0..a9fa4021 100644 --- a/crates/solverforge-config/src/phase.rs +++ b/crates/solverforge-config/src/phase.rs @@ -44,7 +44,8 @@ pub struct ConstructionHeuristicConfig { #[serde(flatten)] pub target: VariableTargetConfig, - /// k for ListKOpt (default 2). + /// Cuts per k-opt move of `list_k_opt` construction, from 2 to 5 (default + /// 2). Other heuristics do not use it, and setting it for them is an error. #[serde(default = "default_k")] pub k: usize, diff --git a/crates/solverforge-solver/WIREFRAME.md b/crates/solverforge-solver/WIREFRAME.md index c587e53b..522d57e6 100644 --- a/crates/solverforge-solver/WIREFRAME.md +++ b/crates/solverforge-solver/WIREFRAME.md @@ -346,6 +346,9 @@ src/ │ │ ├── list_clarke_wright/tests/metric_class.rs — Shared metric-class and owner-feasibility tests │ │ ├── list_clarke_wright/tests/owner_binding.rs — Owner-specific hook binding tests │ │ ├── list_k_opt.rs — ListKOptPhase +│ │ ├── list_k_opt/kernel.rs — Route-local polishing loop and exact 2-opt +│ │ ├── list_k_opt/general.rs — Route-local k-opt for k from 3 to 5 +│ │ ├── list_k_opt/facade.rs — Function-pointer test adapter │ ├── mod_tests_integration.rs — Integration test module declarations │ ├── mod_tests_integration/common.rs — Shared no-op phase fixture │ ├── mod_tests_integration/gates.rs — Shared retained-job gates and blockers diff --git a/crates/solverforge-solver/src/manager/phase_factory/list_k_opt.rs b/crates/solverforge-solver/src/manager/phase_factory/list_k_opt.rs index 0273f8a4..392a43d2 100644 --- a/crates/solverforge-solver/src/manager/phase_factory/list_k_opt.rs +++ b/crates/solverforge-solver/src/manager/phase_factory/list_k_opt.rs @@ -1,6 +1,6 @@ //! Per-route k-opt polishing phase for list-variable problems. //! -//! Applies 2-opt local search to each entity's route independently, +//! Applies k-opt local search (k from 2 to 5) to each entity's route independently, //! improving solution quality after construction without modifying the //! overall assignment structure. @@ -12,6 +12,7 @@ use crate::heuristic::selector::nearby_list_change::CrossEntityDistanceMeter; #[cfg(test)] mod facade; +mod general; mod kernel; #[cfg(test)] @@ -312,4 +313,40 @@ mod tests { crate::manager::SolverTerminalReason::TerminatedByConfig ); } + + fn original_or_sorted(_: &Plan, _: usize, route: &[usize]) -> bool { + route == [1, 4, 5, 2, 3, 6] || route == [1, 2, 3, 4, 5, 6] + } + + fn polish(k: usize) -> Vec { + let plan = Plan { + routes: vec![Route { + visits: vec![1, 4, 5, 2, 3, 6], + }], + score: None, + }; + let mut solver_scope = SolverScope::new(director(plan)); + let mut phase = ListKOptPhase::::new( + k, + entity_count, + get_route, + set_route, + depot, + line_distance, + Some(original_or_sorted), + 0, + ); + phase.solve(&mut solver_scope); + solver_scope.working_solution().routes[0].visits.clone() + } + + #[test] + fn higher_k_moves_a_segment_that_no_reversal_reaches() { + // Sorting needs the segment [4, 5] moved behind [2, 3] unreversed: + // one 3-opt reconnection, and no single 2-opt reversal. + assert_eq!(polish(2), [1, 4, 5, 2, 3, 6]); + for k in 3..=5 { + assert_eq!(polish(k), [1, 2, 3, 4, 5, 6], "k = {k}"); + } + } } diff --git a/crates/solverforge-solver/src/manager/phase_factory/list_k_opt/facade.rs b/crates/solverforge-solver/src/manager/phase_factory/list_k_opt/facade.rs index ad5729e1..b7780cb3 100644 --- a/crates/solverforge-solver/src/manager/phase_factory/list_k_opt/facade.rs +++ b/crates/solverforge-solver/src/manager/phase_factory/list_k_opt/facade.rs @@ -9,16 +9,18 @@ use std::marker::PhantomData; /// Per-route k-opt polishing phase for list variable problems. /// -/// Runs 2-opt local search on each entity's route to local optimum after +/// Runs k-opt local search on each entity's route to local optimum after /// construction. All domain knowledge is supplied via function pointers. /// -/// # Algorithm (k=2, 2-opt) +/// # Algorithm /// /// For each entity: /// 1. Read the current route as a sequence of element indices. -/// 2. Try every segment reversal `[i..=j]`. -/// 3. Accept if distance improves AND `feasible_fn` passes (if provided). -/// 4. Repeat until no improving reversal exists. +/// 2. k = 2: try every segment reversal `[i..=j]`. k from 3 to 5: try every +/// reconnection pattern of every set of k cuts. +/// 3. Accept the first move that improves distance AND passes `feasible_fn` +/// (if provided). +/// 4. Repeat until no improving move exists. pub(crate) struct ListKOptPhase where S: PlanningSolution, @@ -54,7 +56,7 @@ where /// /// # Arguments /// - /// * `k` — k value; only k=2 (2-opt) is implemented; k>2 logs a warning and is a no-op + /// * `k` — cuts per move, from 2 to 5 /// * `entity_count` — number of entities (routes) in the solution /// * `get_route` — returns the route for entity at given index as element indices /// * `set_route` — replaces the route for entity at given index diff --git a/crates/solverforge-solver/src/manager/phase_factory/list_k_opt/general.rs b/crates/solverforge-solver/src/manager/phase_factory/list_k_opt/general.rs new file mode 100644 index 00000000..35cc0a5f --- /dev/null +++ b/crates/solverforge-solver/src/manager/phase_factory/list_k_opt/general.rs @@ -0,0 +1,325 @@ +//! Route-local k-opt polishing for k from 3 to 5. +//! +//! A route `r[0..n]` sits between its depot at both ends. k cuts at edge +//! positions `e_1 < … < e_k` in `0..=n` (edge `e` joins `r[e - 1]` and `r[e]`, +//! the depot standing in at `-1` and `n`) split it into a fixed head +//! `r[0..e_1]`, `k - 1` non-empty middle segments, and a fixed tail +//! `r[e_k..n]`. Every reconnection pattern of `enumerate_reconnections(k)` +//! reorders and reverses the middle segments. A candidate's distance delta +//! is exact in O(k), also for asymmetric distances: the boundary edges are +//! read directly and each middle segment's inner cost comes from forward and +//! backward prefix sums. +//! +//! Cut sets are scanned in lexicographic order and patterns in their +//! enumeration order. The first improving, feasible reconnection is applied, +//! and the scan restarts until no improving reconnection is left. + +use solverforge_core::domain::PlanningSolution; +use solverforge_scoring::Director; + +use super::kernel::ListKOptAccess; +use crate::heuristic::r#move::k_opt_reconnection::{enumerate_reconnections, KOptReconnection}; +use crate::phase::construction::{record_construction_candidate, PendingConstructionMoveTelemetry}; +use crate::scope::{PhaseScope, ProgressCallback, StepControlPolicy}; +use crate::stats::{ + CandidateTraceConstructionTarget, CandidateTraceDisposition, CandidateTraceSource, +}; + +/// Polishes `route` with k-opt and returns `(changed, interrupted)`. +#[allow(clippy::too_many_arguments)] +pub(super) fn polish_route( + access: &A, + k: usize, + control_policy: StepControlPolicy, + phase_scope: &mut PhaseScope<'_, '_, S, D, BestCb>, + entity_idx: usize, + depot: usize, + route: &mut Vec, + pending_move_telemetry: &mut PendingConstructionMoveTelemetry, +) -> (bool, bool) +where + S: PlanningSolution, + A: ListKOptAccess, + D: Director, + BestCb: ProgressCallback, +{ + let patterns = enumerate_reconnections(k); + let descriptor_index = access.descriptor_index(); + let mut changed = false; + let mut candidate_index = 0usize; + 'scan: loop { + let n = route.len(); + let costs = { + let solution = phase_scope.score_director().working_solution(); + RouteCosts::new(depot, route, |from, to| { + access.route_distance(solution, entity_idx, from, to) + }) + }; + let mut cuts: Vec = (0..k).collect(); + if k > n + 1 { + return (changed, false); + } + loop { + for (pattern_index, pattern) in patterns.iter().enumerate() { + if control_policy.should_terminate_construction(phase_scope.solver_scope_mut()) { + return (changed, true); + } + let components = std::iter::once(entity_idx as u64) + .chain(cuts.iter().map(|&cut| cut as u64)) + .chain(std::iter::once(pattern_index as u64)); + let trace_token = phase_scope.record_candidate_operation( + CandidateTraceSource::ListKOptReconnection, + None, + candidate_index, + Some(CandidateTraceConstructionTarget { + descriptor_index, + entity_index: entity_idx, + }), + descriptor_index, + "list_k_opt_reconnection", + components, + ); + candidate_index += 1; + let delta = costs.delta(&cuts, pattern); + if let Some(token) = trace_token { + phase_scope.record_candidate_trace_disposition( + token, + CandidateTraceDisposition::Evaluated, + ); + } + record_construction_candidate( + phase_scope, + std::time::Duration::ZERO, + std::time::Duration::ZERO, + ); + if delta < 0 { + let candidate = reconnect(route, &cuts, pattern); + if access.route_feasible( + phase_scope.score_director().working_solution(), + entity_idx, + &candidate, + ) { + *route = candidate; + pending_move_telemetry.record_accepted(phase_scope, trace_token); + changed = true; + continue 'scan; + } + } + if let Some(token) = trace_token { + phase_scope.record_candidate_trace_disposition( + token, + CandidateTraceDisposition::ForagerIgnored, + ); + } + } + if !next_cut_set(&mut cuts, n) { + return (changed, false); + } + } + } +} + +/// Advances `cuts` to the next strictly increasing k-subset of `0..=n` in +/// lexicographic order; `false` after the last one. +fn next_cut_set(cuts: &mut [usize], n: usize) -> bool { + let k = cuts.len(); + for position in (0..k).rev() { + let ceiling = n + 1 - (k - position); + if cuts[position] < ceiling { + cuts[position] += 1; + for next in position + 1..k { + cuts[next] = cuts[next - 1] + 1; + } + return true; + } + } + false +} + +/// The route after applying `pattern` at `cuts`. +fn reconnect(route: &[usize], cuts: &[usize], pattern: &KOptReconnection) -> Vec { + let mut bounds = Vec::with_capacity(cuts.len() + 2); + bounds.push(0); + bounds.extend_from_slice(cuts); + bounds.push(route.len()); + let mut result = Vec::with_capacity(route.len()); + for position in 0..pattern.segment_count() { + let segment = pattern.segment_at(position); + let slice = &route[bounds[segment]..bounds[segment + 1]]; + if pattern.should_reverse(segment) { + result.extend(slice.iter().rev()); + } else { + result.extend_from_slice(slice); + } + } + result +} + +/// Distances of one route: the endpoints, and forward and backward prefix +/// sums of its inner edges. +struct RouteCosts { + depot: usize, + route: Vec, + forward: Vec, + backward: Vec, + distance: Vec>, +} + +impl RouteCosts { + /// `distance(from, to)` is the distance between two elements, the depot + /// included. + fn new(depot: usize, route: &[usize], distance: impl Fn(usize, usize) -> i64) -> Self { + // Nodes: the route elements, then the depot at index `n`. + let n = route.len(); + let node = |index: usize| if index == n { depot } else { route[index] }; + let distance = (0..=n) + .map(|from| { + (0..=n) + .map(|to| i128::from(distance(node(from), node(to)))) + .collect() + }) + .collect::>>(); + let mut forward = vec![0i128; n.max(1)]; + let mut backward = vec![0i128; n.max(1)]; + for t in 1..n { + forward[t] = forward[t - 1] + distance[t - 1][t]; + backward[t] = backward[t - 1] + distance[t][t - 1]; + } + Self { + depot: n, + route: route.to_vec(), + forward, + backward, + distance, + } + } + + /// Inner cost of `route[start..end]` (non-empty), forward or reversed. + fn inner(&self, start: usize, end: usize, reversed: bool) -> i128 { + let prefix = if reversed { + &self.backward + } else { + &self.forward + }; + prefix[end - 1] - prefix[start] + } + + /// Distance change of `pattern` at `cuts`; negative improves. + fn delta(&self, cuts: &[usize], pattern: &KOptReconnection) -> i128 { + let k = cuts.len(); + let n = self.route.len(); + debug_assert_eq!(self.depot, n); + // The head ends at `cuts[0] - 1` (or the depot), the tail starts at + // `cuts[k - 1]` (or the depot); both stay in place. + let head_end = if cuts[0] == 0 { + self.depot + } else { + cuts[0] - 1 + }; + let tail_start = if cuts[k - 1] == n { + self.depot + } else { + cuts[k - 1] + }; + let middle = |segment: usize| (cuts[segment - 1], cuts[segment]); + let cost = |order: &mut dyn Iterator| { + let mut total = 0i128; + let mut previous = head_end; + for (segment, reversed) in order { + let (start, end) = middle(segment); + let (first, last) = if reversed { + (end - 1, start) + } else { + (start, end - 1) + }; + total += self.distance[previous][first] + self.inner(start, end, reversed); + previous = last; + } + total + self.distance[previous][tail_start] + }; + let current = cost(&mut (1..k).map(|segment| (segment, false))); + let proposed = cost(&mut (1..k).map(|position| { + let segment = pattern.segment_at(position); + (segment, pattern.should_reverse(segment)) + })); + proposed - current + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cut_sets_cover_every_increasing_subset_once() { + let mut cuts = vec![0, 1, 2]; + let mut seen = vec![cuts.clone()]; + while next_cut_set(&mut cuts, 4) { + seen.push(cuts.clone()); + } + // C(5, 3) subsets of 0..=4. + assert_eq!(seen.len(), 10); + assert!(seen.windows(2).all(|pair| pair[0] < pair[1])); + } + + #[test] + fn reconnect_reorders_and_reverses_middle_segments() { + // A=[0] B=[1,2] C=[3] D=[4]; A-C'-B'-D. + let pattern = KOptReconnection::new([0, 2, 1, 3, 0, 0], 0b0110, 4); + assert_eq!( + reconnect(&[0, 1, 2, 3, 4], &[1, 3, 4], &pattern), + [0, 3, 2, 1, 4] + ); + } + + /// Deterministic asymmetric distances: going "down" costs extra. + fn asymmetric(from: usize, to: usize) -> i64 { + let base = ((from * 7 + to * 13) % 11) as i64; + if from > to { + base + 5 + } else { + base + } + } + + fn route_cost(depot: usize, route: &[usize]) -> i128 { + let mut previous = depot; + let mut total = 0i128; + for &element in route { + total += i128::from(asymmetric(previous, element)); + previous = element; + } + total + i128::from(asymmetric(previous, depot)) + } + + #[test] + fn deltas_are_exact_for_every_cut_set_and_pattern() { + let depot = 0; + let route = [4, 1, 6, 2, 7, 3, 5]; + let costs = RouteCosts::new(depot, &route, asymmetric); + let base = route_cost(depot, &route); + let mut checked = 0; + for k in 3..=5 { + let patterns = enumerate_reconnections(k); + let mut cuts: Vec = (0..k).collect(); + loop { + for pattern in &patterns { + let candidate = reconnect(&route, &cuts, pattern); + let mut sorted = candidate.clone(); + sorted.sort_unstable(); + assert_eq!(sorted, [1, 2, 3, 4, 5, 6, 7], "{cuts:?}"); + assert_eq!( + costs.delta(&cuts, pattern), + route_cost(depot, &candidate) - base, + "k {k}, cuts {cuts:?}, candidate {candidate:?}" + ); + checked += 1; + } + if !next_cut_set(&mut cuts, route.len()) { + break; + } + } + } + assert!(checked > 0); + } +} diff --git a/crates/solverforge-solver/src/manager/phase_factory/list_k_opt/kernel.rs b/crates/solverforge-solver/src/manager/phase_factory/list_k_opt/kernel.rs index 594b2f28..7fec93c1 100644 --- a/crates/solverforge-solver/src/manager/phase_factory/list_k_opt/kernel.rs +++ b/crates/solverforge-solver/src/manager/phase_factory/list_k_opt/kernel.rs @@ -1,4 +1,5 @@ -//! Canonical route-local 2-opt execution. +//! Canonical route-local k-opt execution: exact 2-opt here, k from 3 to 5 +//! in [`super::general`]. //! //! The public function-pointer facade and compiled `RuntimeListSlot` both //! implement [`ListKOptAccess`]. This module owns the only candidate order, @@ -33,10 +34,9 @@ pub(crate) trait ListKOptAccess { /// Runs the one canonical list K-opt kernel. /// -/// The public phase keeps its historic `k` contract: only `k = 2` is -/// implemented, and every other value is a scored no-op. The caller supplies -/// a fully resolved access object; source binding is deliberately absent -/// because K-opt never consumes an assignment source stream. +/// `k` is 2 to 5; callers validate it. The caller supplies a fully resolved +/// access object; source binding is deliberately absent because K-opt never +/// consumes an assignment source stream. pub(crate) fn run_list_k_opt( access: &A, k: usize, @@ -64,15 +64,10 @@ fn run_list_k_opt_in_phase( D: Director, BestCb: ProgressCallback, { - if k != 2 { - tracing::warn!( - k, - "ListKOptPhase: only k=2 is implemented; skipping k-opt polishing" - ); - phase_scope.calculate_score(); - phase_scope.update_best_solution(); - return; - } + assert!( + (2..=5).contains(&k), + "list k-opt supports k from 2 to 5, got {k}" + ); let n_entities = { let solution = phase_scope.score_director().working_solution(); @@ -114,94 +109,108 @@ fn run_list_k_opt_in_phase( let mut interrupted = false; let mut pending_move_telemetry = PendingConstructionMoveTelemetry::default(); - // 2-opt: try all (i, j) segment reversals. - loop { - let mut improved = false; - for i in 0..n - 1 { - let a = if i == 0 { depot } else { route[i - 1] }; - let b = route[i]; - for j in i + 1..n { - if control_policy.should_terminate_construction(phase_scope.solver_scope_mut()) - { - interrupted = true; - break; - } - let c = route[j]; - let e = if j + 1 < n { route[j + 1] } else { depot }; - let trace_token = phase_scope.record_candidate_operation( - CandidateTraceSource::ListKOptReconnection, - None, - j, - Some(CandidateTraceConstructionTarget { + if k > 2 { + (changed, interrupted) = super::general::polish_route( + access, + k, + control_policy, + phase_scope, + entity_idx, + depot, + &mut route, + &mut pending_move_telemetry, + ); + } else { + // 2-opt: try all (i, j) segment reversals. + loop { + let mut improved = false; + for i in 0..n - 1 { + let a = if i == 0 { depot } else { route[i - 1] }; + let b = route[i]; + for j in i + 1..n { + if control_policy + .should_terminate_construction(phase_scope.solver_scope_mut()) + { + interrupted = true; + break; + } + let c = route[j]; + let e = if j + 1 < n { route[j + 1] } else { depot }; + let trace_token = phase_scope.record_candidate_operation( + CandidateTraceSource::ListKOptReconnection, + None, + j, + Some(CandidateTraceConstructionTarget { + descriptor_index, + entity_index: entity_idx, + }), descriptor_index, - entity_index: entity_idx, - }), - descriptor_index, - "list_k_opt_reconnection", - [ - entity_idx as u64, - i as u64, - j as u64, - a as u64, - b as u64, - c as u64, - e as u64, - ], - ); - // Accept if reversing [i..=j] reduces distance. - let solution = phase_scope.score_director().working_solution(); - let proposed_distance = sum_two( - access.route_distance(solution, entity_idx, a, c), - access.route_distance(solution, entity_idx, b, e), - ); - let current_distance = sum_two( - access.route_distance(solution, entity_idx, a, b), - access.route_distance(solution, entity_idx, c, e), - ); - if let Some(token) = trace_token { - phase_scope.record_candidate_trace_disposition( - token, - CandidateTraceDisposition::Evaluated, + "list_k_opt_reconnection", + [ + entity_idx as u64, + i as u64, + j as u64, + a as u64, + b as u64, + c as u64, + e as u64, + ], ); - } - record_construction_candidate( - phase_scope, - std::time::Duration::ZERO, - std::time::Duration::ZERO, - ); - if proposed_distance < current_distance { - route[i..=j].reverse(); - if !access.route_feasible( - phase_scope.score_director().working_solution(), - entity_idx, - &route, - ) { + // Accept if reversing [i..=j] reduces distance. + let solution = phase_scope.score_director().working_solution(); + let proposed_distance = sum_two( + access.route_distance(solution, entity_idx, a, c), + access.route_distance(solution, entity_idx, b, e), + ); + let current_distance = sum_two( + access.route_distance(solution, entity_idx, a, b), + access.route_distance(solution, entity_idx, c, e), + ); + if let Some(token) = trace_token { + phase_scope.record_candidate_trace_disposition( + token, + CandidateTraceDisposition::Evaluated, + ); + } + record_construction_candidate( + phase_scope, + std::time::Duration::ZERO, + std::time::Duration::ZERO, + ); + if proposed_distance < current_distance { route[i..=j].reverse(); - if let Some(token) = trace_token { - phase_scope.record_candidate_trace_disposition( - token, - CandidateTraceDisposition::ForagerIgnored, - ); + if !access.route_feasible( + phase_scope.score_director().working_solution(), + entity_idx, + &route, + ) { + route[i..=j].reverse(); + if let Some(token) = trace_token { + phase_scope.record_candidate_trace_disposition( + token, + CandidateTraceDisposition::ForagerIgnored, + ); + } + continue; } - continue; + pending_move_telemetry.record_accepted(phase_scope, trace_token); + improved = true; + changed = true; + } else if let Some(token) = trace_token { + phase_scope.record_candidate_trace_disposition( + token, + CandidateTraceDisposition::ForagerIgnored, + ); } - pending_move_telemetry.record_accepted(phase_scope, trace_token); - improved = true; - changed = true; - } else if let Some(token) = trace_token { - phase_scope.record_candidate_trace_disposition( - token, - CandidateTraceDisposition::ForagerIgnored, - ); + } + if interrupted { + break; } } - if interrupted { + if interrupted || !improved { break; } } - if interrupted || !improved { - break; - } } if interrupted { diff --git a/crates/solverforge-solver/src/runtime/compiler/selector_values.rs b/crates/solverforge-solver/src/runtime/compiler/selector_values.rs index e2534a6b..1addbde2 100644 --- a/crates/solverforge-solver/src/runtime/compiler/selector_values.rs +++ b/crates/solverforge-solver/src/runtime/compiler/selector_values.rs @@ -6,8 +6,8 @@ //! rejected with its key path before any phase runs. use solverforge_config::{ - ConstructionHeuristicConfig, KOptMoveSelectorConfig, ListPermuteMoveConfig, MoveSelectorConfig, - PhaseConfig, SolverConfig, + ConstructionHeuristicConfig, ConstructionHeuristicType, KOptMoveSelectorConfig, + ListPermuteMoveConfig, MoveSelectorConfig, PhaseConfig, SolverConfig, }; use crate::heuristic::r#move::MAX_LIST_PERMUTE_WINDOW_SIZE; @@ -17,8 +17,8 @@ use super::types::{RuntimeCompileError, RuntimeCompileErrorKind}; /// Largest k the k-opt reconnection kernel enumerates. const MAX_K_OPT_K: usize = 5; -/// The only k the list k-opt construction kernel implements. -const LIST_K_OPT_CONSTRUCTION_K: usize = 2; +/// The `k` of a construction phase whose heuristic does not use it. +const DEFAULT_CONSTRUCTION_K: usize = 2; /// Rejects every out-of-domain numeric move-selector or construction value in /// `config`, including nested selectors and partitioned child phases. @@ -56,12 +56,20 @@ fn validate_construction( config: &ConstructionHeuristicConfig, path: &str, ) -> Result<(), RuntimeCompileError> { - if config.k != LIST_K_OPT_CONSTRUCTION_K { + if config.construction_heuristic_type == ConstructionHeuristicType::ListKOpt { + if !(2..=MAX_K_OPT_K).contains(&config.k) { + return Err(invalid( + path, + "k", + format!("must be between 2 and {MAX_K_OPT_K}, got {}", config.k), + )); + } + } else if config.k != DEFAULT_CONSTRUCTION_K { return Err(invalid( path, "k", format!( - "must be {LIST_K_OPT_CONSTRUCTION_K}: list k-opt construction implements only 2-opt, got {}", + "applies only to `list_k_opt` construction; leave it at {DEFAULT_CONSTRUCTION_K}, got {}", config.k ), )); diff --git a/crates/solverforge-solver/src/runtime/compiler/tests/invalid_selector_values.rs b/crates/solverforge-solver/src/runtime/compiler/tests/invalid_selector_values.rs index 19011cc1..37ec0261 100644 --- a/crates/solverforge-solver/src/runtime/compiler/tests/invalid_selector_values.rs +++ b/crates/solverforge-solver/src/runtime/compiler/tests/invalid_selector_values.rs @@ -219,6 +219,21 @@ fn construction_values_reject_their_invalid_value_with_the_key_path() { assert_valid(&construction(""), "defaults"); } +#[test] +fn list_k_opt_construction_accepts_k_from_two_to_five() { + let list_k_opt = |k: usize| { + construction(&format!( + "construction_heuristic_type = \"list_k_opt\"\nk = {k}" + )) + }; + for k in 2..=5 { + assert_valid(&list_k_opt(k), &format!("k = {k}")); + } + for k in [0, 1, 6] { + assert_invalid(&list_k_opt(k), "phases[0].construction.k"); + } +} + #[test] fn partitioned_child_phase_values_are_validated() { let config = parse( diff --git a/crates/solverforge/tests/list_k_opt_construction.rs b/crates/solverforge/tests/list_k_opt_construction.rs new file mode 100644 index 00000000..6b23a01d --- /dev/null +++ b/crates/solverforge/tests/list_k_opt_construction.rs @@ -0,0 +1,38 @@ +//! `list_k_opt` construction polishes routes with the configured `k`. + +use solverforge::SolverConfig; + +#[path = "list_k_opt_construction/domain/mod.rs"] +mod domain; + +use domain::sorting_plan; + +fn polish_with_k(k: usize) -> Vec { + let config = SolverConfig::from_toml_str(&format!( + r#" +environment_mode = "reproducible" + +[[phases]] +type = "construction_heuristic" +construction_heuristic_type = "list_k_opt" +entity_class = "Route" +variable_name = "visits" +k = {k} +"# + )) + .expect("the test config parses"); + sorting_plan() + .solve_with_config(config) + .expect("solves") + .routes[0] + .visits + .clone() +} + +#[test] +fn configured_k_reaches_the_list_k_opt_construction_kernel() { + assert_eq!(polish_with_k(2), [1, 4, 5, 2, 3, 6]); + for k in 3..=5 { + assert_eq!(polish_with_k(k), [1, 2, 3, 4, 5, 6], "k = {k}"); + } +} diff --git a/crates/solverforge/tests/list_k_opt_construction/domain/mod.rs b/crates/solverforge/tests/list_k_opt_construction/domain/mod.rs new file mode 100644 index 00000000..862aeba4 --- /dev/null +++ b/crates/solverforge/tests/list_k_opt_construction/domain/mod.rs @@ -0,0 +1,9 @@ +solverforge::planning_model! { + root = "crates/solverforge/tests/list_k_opt_construction/domain"; + + mod plan; + mod route; + + pub use plan::sorting_plan; + pub use route::Route; +} diff --git a/crates/solverforge/tests/list_k_opt_construction/domain/plan.rs b/crates/solverforge/tests/list_k_opt_construction/domain/plan.rs new file mode 100644 index 00000000..5b158b7f --- /dev/null +++ b/crates/solverforge/tests/list_k_opt_construction/domain/plan.rs @@ -0,0 +1,93 @@ +use std::sync::Arc; + +use solverforge::cvrp::ProblemData; +use solverforge::prelude::*; +use solverforge::stream::ConstraintFactory; + +use super::Route; + +#[planning_solution(constraints = "constraints")] +pub struct Plan { + #[planning_list_element_collection(owner = "routes")] + pub customer_values: Vec, + + #[planning_entity_collection] + pub routes: Vec, + + #[planning_score] + pub score: Option, + + pub shared: Arc, +} + +impl solverforge::cvrp::VrpSolution for Plan { + fn vehicle_data(&self, _entity_idx: usize) -> Option<&ProblemData> { + Some(&self.shared) + } + + fn vehicle_visits(&self, entity_idx: usize) -> &[usize] { + &self.routes[entity_idx].visits + } + + fn vehicle_visits_mut(&mut self, entity_idx: usize) -> &mut Vec { + &mut self.routes[entity_idx].visits + } + + fn vehicle_count(&self) -> usize { + self.routes.len() + } +} + +/// Distance along a line from the depot at 0 and back, the same metric as +/// the problem data's distance matrix. +fn line_distance(route: &Route) -> i64 { + let mut previous = 0usize; + let mut total = 0i64; + for &visit in route.visits.iter().chain(std::iter::once(&0)) { + total += (previous as i64 - visit as i64).abs(); + previous = visit; + } + total +} + +fn constraints() -> impl ConstraintSet { + (ConstraintFactory::::new() + .for_each(Plan::routes()) + .penalize(|route: &Route| HardSoftScore::of(0, line_distance(route))) + .named("route distance"),) +} + +/// Six customers on a line, travel time 10 per hop, customer `c` due at +/// `10 * c`: only the sorted route is on time. From `[1, 4, 5, 2, 3, 6]` no +/// single reversal reaches it, one 3-opt segment move does. +pub fn sorting_plan() -> Plan { + let size = 7; + let distance_matrix = (0..size) + .map(|from| (0..size).map(|to| (from as i64 - to as i64).abs()).collect()) + .collect(); + let travel_times = (0..size) + .map(|from| (0..size).map(|to| if from == to { 0 } else { 10 }).collect()) + .collect(); + let time_windows = (0..size) + .map(|customer| (0, if customer == 0 { 1_000 } else { 10 * customer as i64 })) + .collect(); + let shared = Arc::new(ProblemData { + capacity: 100, + depot: 0, + demands: vec![0, 1, 1, 1, 1, 1, 1], + distance_matrix, + time_windows, + service_durations: vec![0; size], + travel_times, + vehicle_departure_time: 0, + }); + Plan { + customer_values: vec![1, 2, 3, 4, 5, 6], + routes: vec![Route { + id: 0, + visits: vec![1, 4, 5, 2, 3, 6], + }], + score: None, + shared, + } +} diff --git a/crates/solverforge/tests/list_k_opt_construction/domain/route.rs b/crates/solverforge/tests/list_k_opt_construction/domain/route.rs new file mode 100644 index 00000000..c2f04b19 --- /dev/null +++ b/crates/solverforge/tests/list_k_opt_construction/domain/route.rs @@ -0,0 +1,13 @@ +use solverforge::prelude::*; + +#[planning_entity] +pub struct Route { + #[planning_id] + pub id: usize, + + #[planning_list_variable( + element_collection = "customer_values", + domain = "cvrp" + )] + pub visits: Vec, +}