From 161ceb1c2527dad65101fd9caf96bb2849c2cb88 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Thu, 5 Nov 2026 01:20:53 +0100 Subject: [PATCH 1/2] feat(list_k_opt): polish routes with k-opt for k from 3 to 5 The route-local list k-opt kernel implemented only 2-opt and skipped polishing with a warning for any other `k`. For k from 3 to 5 it now scans every set of k cuts in lexicographic order and every reconnection pattern of `enumerate_reconnections(k)`, applies the first improving feasible one and rescans until none is left. Each candidate's distance delta is exact in O(k), asymmetric distances included, from the boundary edges and forward and backward prefix sums. k = 2 keeps its exact 2-opt order and trace identities. --- crates/solverforge-solver/WIREFRAME.md | 3 + .../src/manager/phase_factory/list_k_opt.rs | 39 ++- .../phase_factory/list_k_opt/facade.rs | 14 +- .../phase_factory/list_k_opt/general.rs | 325 ++++++++++++++++++ .../phase_factory/list_k_opt/kernel.rs | 193 ++++++----- 5 files changed, 475 insertions(+), 99 deletions(-) create mode 100644 crates/solverforge-solver/src/manager/phase_factory/list_k_opt/general.rs diff --git a/crates/solverforge-solver/WIREFRAME.md b/crates/solverforge-solver/WIREFRAME.md index c587e53b5..522d57e6d 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 0273f8a40..392a43d22 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 ad5729e1e..b7780cb3b 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 000000000..35cc0a5f9 --- /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 594b2f287..7fec93c1a 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 { From 6ea7773d84d1543e391a2cdd3b22ed5e0a7ce197 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Thu, 5 Nov 2026 01:22:18 +0100 Subject: [PATCH 2/2] feat(construction): accept k from 2 to 5 for list_k_opt construction The compiler rejected every construction `k` but 2 ("list k-opt construction implements only 2-opt"). `list_k_opt` construction now accepts k from 2 to 5 and polishes each route with that k; values outside that range are rejected with the key path. Other heuristics do not use `k` and still reject any value but the default 2, now with a message saying so. Closes #179. --- crates/solverforge-config/WIREFRAME.md | 2 +- crates/solverforge-config/src/phase.rs | 3 +- .../src/runtime/compiler/selector_values.rs | 20 ++-- .../compiler/tests/invalid_selector_values.rs | 15 +++ .../tests/list_k_opt_construction.rs | 38 ++++++++ .../list_k_opt_construction/domain/mod.rs | 9 ++ .../list_k_opt_construction/domain/plan.rs | 93 +++++++++++++++++++ .../list_k_opt_construction/domain/route.rs | 13 +++ 8 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 crates/solverforge/tests/list_k_opt_construction.rs create mode 100644 crates/solverforge/tests/list_k_opt_construction/domain/mod.rs create mode 100644 crates/solverforge/tests/list_k_opt_construction/domain/plan.rs create mode 100644 crates/solverforge/tests/list_k_opt_construction/domain/route.rs diff --git a/crates/solverforge-config/WIREFRAME.md b/crates/solverforge-config/WIREFRAME.md index 32ce35f3c..691d0ecb3 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 58e6d8b03..a9fa40216 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/src/runtime/compiler/selector_values.rs b/crates/solverforge-solver/src/runtime/compiler/selector_values.rs index e2534a6bc..1addbde2c 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 19011cc1a..37ec0261a 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 000000000..6b23a01d7 --- /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 000000000..862aeba49 --- /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 000000000..5b158b7f1 --- /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 000000000..c2f04b19f --- /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, +}