From 2d6312c3f1974c505dd36c0586a0a8888f10afc2 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Thu, 5 Nov 2026 09:35:24 +0100 Subject: [PATCH 1/2] test: fail a waiting test when its solve fails parallel_local_search, parallel_list_local_search and scalar_runtime_publication waited for Completed and skipped every other event. A job keeps its event channel open until it is deleted, so a solve that failed left the test blocked in blocking_recv forever instead of failing. They now pass over best-solution and progress events only, and panic with the error on Failed and with the event on anything else; a solve that cannot prepare its phases now fails these tests in seconds. --- .../tests/parallel_list_local_search.rs | 19 ++++++++++++------- .../tests/parallel_local_search.rs | 19 ++++++++++++------- .../tests/scalar_runtime_publication.rs | 6 +++--- 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/crates/solverforge/tests/parallel_list_local_search.rs b/crates/solverforge/tests/parallel_list_local_search.rs index aead2513e..e216d5816 100644 --- a/crates/solverforge/tests/parallel_list_local_search.rs +++ b/crates/solverforge/tests/parallel_list_local_search.rs @@ -45,13 +45,18 @@ fn solve(manager: &'static SolverManager, move_threads: usize) -> Run .expect("job should start"); let mut completed = None; while let Some(event) = receiver.blocking_recv() { - if let SolverEvent::Completed { metadata, solution } = event { - assert_eq!( - metadata.terminal_reason, - Some(SolverTerminalReason::Completed) - ); - completed = Some((solution, metadata.telemetry)); - break; + match event { + SolverEvent::BestSolution { .. } | SolverEvent::Progress { .. } => {} + SolverEvent::Completed { metadata, solution } => { + assert_eq!( + metadata.terminal_reason, + Some(SolverTerminalReason::Completed) + ); + completed = Some((solution, metadata.telemetry)); + break; + } + SolverEvent::Failed { error, .. } => panic!("the solve failed: {error}"), + other => panic!("unexpected event: {other:?}"), } } let (plan, telemetry) = completed.expect("expected a completed solution"); diff --git a/crates/solverforge/tests/parallel_local_search.rs b/crates/solverforge/tests/parallel_local_search.rs index 8f30e4efe..7e4f8a3d9 100644 --- a/crates/solverforge/tests/parallel_local_search.rs +++ b/crates/solverforge/tests/parallel_local_search.rs @@ -41,13 +41,18 @@ fn solve(manager: &'static SolverManager, move_threads: usize) -> Run { .expect("job should start"); let mut completed = None; while let Some(event) = receiver.blocking_recv() { - if let SolverEvent::Completed { metadata, solution } = event { - assert_eq!( - metadata.terminal_reason, - Some(SolverTerminalReason::Completed) - ); - completed = Some((solution, metadata.telemetry)); - break; + match event { + SolverEvent::BestSolution { .. } | SolverEvent::Progress { .. } => {} + SolverEvent::Completed { metadata, solution } => { + assert_eq!( + metadata.terminal_reason, + Some(SolverTerminalReason::Completed) + ); + completed = Some((solution, metadata.telemetry)); + break; + } + SolverEvent::Failed { error, .. } => panic!("the solve failed: {error}"), + other => panic!("unexpected event: {other:?}"), } } let (plan, telemetry) = completed.expect("expected a completed solution"); diff --git a/crates/solverforge/tests/scalar_runtime_publication.rs b/crates/solverforge/tests/scalar_runtime_publication.rs index ac6917548..6b37c521b 100644 --- a/crates/solverforge/tests/scalar_runtime_publication.rs +++ b/crates/solverforge/tests/scalar_runtime_publication.rs @@ -43,9 +43,9 @@ fn scalar_only_solution_runs_construction_in_retained_runtime() { completed_solution = Some(solution); break; } - other => { - eprintln!("event={other:?}"); - } + SolverEvent::Progress { .. } => {} + SolverEvent::Failed { error, .. } => panic!("the solve failed: {error}"), + other => panic!("unexpected event: {other:?}"), } } From c5414198437ed541fc40f085d2c1422f67820c57 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Thu, 5 Nov 2026 09:36:00 +0100 Subject: [PATCH 2/2] test(localsearch): pin what swap moves do A swap cursor that emitted no moves passed every test. A solve configured with only swap moves now starts from two tasks on each other's cheaper worker and asserts that the search exchanges them. --- .../solverforge/tests/local_search_config.rs | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/solverforge/tests/local_search_config.rs b/crates/solverforge/tests/local_search_config.rs index 7b6a21a13..cfb25f164 100644 --- a/crates/solverforge/tests/local_search_config.rs +++ b/crates/solverforge/tests/local_search_config.rs @@ -1,5 +1,5 @@ //! Local search settings that only show in a solve: the recreate heuristic of -//! ruin-and-recreate moves, and that each random part of a search +//! ruin-and-recreate moves, swap moves, and that each random part of a search //! (simulated annealing, random tie breaks, ruin choice) follows //! `random_seed`. @@ -65,6 +65,32 @@ fn first_fit_recreate_puts_ruined_tasks_back_on_the_first_worker() { assert_eq!(solved.workers_by_task(), [Some(0), Some(0)]); } +#[test] +fn swap_moves_exchange_two_tasks_workers() { + // Task 0 (weight 5) on worker 1 (cost 3) and task 1 (weight 1) on + // worker 0 (cost 1) cost 16; exchanging their workers costs 8. Only swap + // moves are configured, so only an exchange can improve. + let mut plan = Plan::new(&[5, 1], &[1, 3]); + plan.tasks[0].worker = Some(1); + plan.tasks[1].worker = Some(0); + let solved = solve( + plan, + r#"random_seed = 7 + +[[phases]] +type = "local_search" +[phases.termination] +step_count_limit = 10 +[phases.acceptor] +type = "hill_climbing" +[phases.move_selector] +type = "swap_move_selector" +"#, + ); + assert_eq!(solved.workers_by_task(), [Some(0), Some(1)]); + assert_eq!(solved.score, Some(SoftScore::of(-8))); +} + /// First fit construction, then local search configured by `local_search`, on /// twelve tasks weighing 0 to 8 and four workers costing 1 to 4: many local /// optima and equally scored moves, so a random part decides where a bounded