From 460c36747f207b279f588e9cc33cae2ce20781c2 Mon Sep 17 00:00:00 2001 From: krilar Date: Tue, 3 Feb 2026 14:29:25 +0100 Subject: [PATCH 1/5] feat: add tags constraint and tests --- vrp-core/src/construction/features/mod.rs | 3 + vrp-core/src/construction/features/tags.rs | 65 ++++++++++++ .../unit/construction/features/tags_test.rs | 100 ++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 vrp-core/src/construction/features/tags.rs create mode 100644 vrp-core/tests/unit/construction/features/tags_test.rs diff --git a/vrp-core/src/construction/features/mod.rs b/vrp-core/src/construction/features/mod.rs index f54ca429f..42ee32ca6 100644 --- a/vrp-core/src/construction/features/mod.rs +++ b/vrp-core/src/construction/features/mod.rs @@ -50,6 +50,9 @@ pub use self::reloads::{ReloadFeatureFactory, ReloadIntervalsTourState, SharedRe mod skills; pub use self::skills::{JobSkills, JobSkillsDimension, VehicleSkillsDimension, create_skills_feature}; +mod tags; +pub use self::tags::{JobTagsDimension, VehicleTagsDimension, create_tags_feature}; + mod total_value; pub use self::total_value::*; diff --git a/vrp-core/src/construction/features/tags.rs b/vrp-core/src/construction/features/tags.rs new file mode 100644 index 000000000..af004ccd2 --- /dev/null +++ b/vrp-core/src/construction/features/tags.rs @@ -0,0 +1,65 @@ +//! A job-vehicle tags feature. + +#[cfg(test)] +#[path = "../../../tests/unit/construction/features/tags_test.rs"] +mod tags_test; + +use super::*; +use std::collections::HashSet; + +custom_dimension!(pub JobTags typeof HashSet); +custom_dimension!(pub VehicleTags typeof HashSet); + +/// Creates a tags feature as hard constraint. +/// +/// A job with tags can only be assigned to a vehicle that has all those tags. +/// If a job has no tags, any vehicle can service it. +pub fn create_tags_feature(name: &str, code: ViolationCode) -> Result { + FeatureBuilder::default().with_name(name).with_constraint(TagsConstraint { code }).build() +} + +struct TagsConstraint { + code: ViolationCode, +} + +impl FeatureConstraint for TagsConstraint { + fn evaluate(&self, move_ctx: &MoveContext<'_>) -> Option { + match move_ctx { + MoveContext::Route { route_ctx, job, .. } => { + if let Some(job_tags) = job.dimens().get_job_tags() { + if !job_tags.is_empty() { + let vehicle_tags = route_ctx.route().actor.vehicle.dimens.get_vehicle_tags(); + + // Vehicle must have all job tags + let has_required_tags = match vehicle_tags { + Some(v_tags) => job_tags.is_subset(v_tags), + None => false, + }; + + if !has_required_tags { + return ConstraintViolation::fail(self.code); + } + } + } + + None + } + MoveContext::Activity { .. } => None, + } + } + + fn merge(&self, source: Job, candidate: Job) -> Result { + let source_tags = source.dimens().get_job_tags(); + let candidate_tags = candidate.dimens().get_job_tags(); + + let has_compatible_tags = match (source_tags, candidate_tags) { + (None, None) | (Some(_), None) | (None, Some(_)) => true, + (Some(source_tags), Some(candidate_tags)) => { + // Both jobs must have the same tags to be merged + source_tags == candidate_tags + } + }; + + if has_compatible_tags { Ok(source) } else { Err(self.code) } + } +} \ No newline at end of file diff --git a/vrp-core/tests/unit/construction/features/tags_test.rs b/vrp-core/tests/unit/construction/features/tags_test.rs new file mode 100644 index 000000000..3a067d0ff --- /dev/null +++ b/vrp-core/tests/unit/construction/features/tags_test.rs @@ -0,0 +1,100 @@ +use crate::helpers::construction::heuristics::TestInsertionContextBuilder; +use crate::helpers::models::problem::{FleetBuilder, TestSingleBuilder, TestVehicleBuilder, test_driver}; +use crate::helpers::models::solution::{RouteBuilder, RouteContextBuilder}; +use crate::construction::features::{create_tags_feature, JobTagsDimension, VehicleTagsDimension}; +use crate::construction::heuristics::MoveContext; +use crate::models::problem::{Job, Vehicle}; +use crate::models::ViolationCode; +use std::collections::HashSet; + +const VIOLATION_CODE: ViolationCode = ViolationCode(1); + +fn create_job_with_tags(tags: Option>) -> Job { + let mut builder = TestSingleBuilder::default(); + + if let Some(tags) = tags { + let tag_set: HashSet = HashSet::from_iter(tags.iter().map(|s| s.to_string())); + builder.dimens_mut().set_job_tags(tag_set); + } + + builder.build_as_job_ref() +} + +fn create_vehicle_with_tags(tags: Option>) -> Vehicle { + let mut builder = TestVehicleBuilder::default(); + + if let Some(tags) = tags { + let tag_set: HashSet = HashSet::from_iter(tags.iter().map(|s| s.to_string())); + builder.dimens_mut().set_vehicle_tags(tag_set); + } + + builder.id("v1").build() +} + +#[test] +fn can_create_tags_feature() { + let feature = create_tags_feature("tags", VIOLATION_CODE).unwrap(); + + assert_eq!(feature.name, "tags"); + assert!(feature.constraint.is_some()); +} + +#[test] +fn job_without_tags_passes_any_vehicle() { + let fleet = FleetBuilder::default() + .add_driver(test_driver()) + .add_vehicle(create_vehicle_with_tags(None)) + .build(); + let route_ctx = + RouteContextBuilder::default().with_route(RouteBuilder::default().with_vehicle(&fleet, "v1").build()).build(); + + let constraint = create_tags_feature("tags", VIOLATION_CODE).unwrap().constraint.unwrap(); + + let actual = constraint.evaluate(&MoveContext::route( + &TestInsertionContextBuilder::default().build().solution, + &route_ctx, + &create_job_with_tags(None), + )); + + assert_eq!(actual, None); +} + +#[test] +fn job_with_tags_fails_without_vehicle_tags() { + let fleet = FleetBuilder::default() + .add_driver(test_driver()) + .add_vehicle(create_vehicle_with_tags(None)) + .build(); + let route_ctx = + RouteContextBuilder::default().with_route(RouteBuilder::default().with_vehicle(&fleet, "v1").build()).build(); + + let constraint = create_tags_feature("tags", VIOLATION_CODE).unwrap().constraint.unwrap(); + + let actual = constraint.evaluate(&MoveContext::route( + &TestInsertionContextBuilder::default().build().solution, + &route_ctx, + &create_job_with_tags(Some(vec!["fragile"])), + )); + + assert!(actual.is_some()); +} + +#[test] +fn job_with_tags_succeeds_with_matching_vehicle_tags() { + let fleet = FleetBuilder::default() + .add_driver(test_driver()) + .add_vehicle(create_vehicle_with_tags(Some(vec!["fragile"]))) + .build(); + let route_ctx = + RouteContextBuilder::default().with_route(RouteBuilder::default().with_vehicle(&fleet, "v1").build()).build(); + + let constraint = create_tags_feature("tags", VIOLATION_CODE).unwrap().constraint.unwrap(); + + let actual = constraint.evaluate(&MoveContext::route( + &TestInsertionContextBuilder::default().build().solution, + &route_ctx, + &create_job_with_tags(Some(vec!["fragile"])), + )); + + assert_eq!(actual, None); +} \ No newline at end of file From d56118669e25a13df4ed976b7e07a49332ac75da Mon Sep 17 00:00:00 2001 From: krilar Date: Tue, 3 Feb 2026 14:55:08 +0100 Subject: [PATCH 2/5] feat: tags constraint example with 4 jobs and 2 vehicles --- vrp-core/examples/tags_constraint_example.rs | 137 +++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 vrp-core/examples/tags_constraint_example.rs diff --git a/vrp-core/examples/tags_constraint_example.rs b/vrp-core/examples/tags_constraint_example.rs new file mode 100644 index 000000000..1088b1eb7 --- /dev/null +++ b/vrp-core/examples/tags_constraint_example.rs @@ -0,0 +1,137 @@ +//! This example demonstrates how to use a custom tags constraint to restrict +//! job assignments based on vehicle capabilities. +//! +//! In this scenario: +//! - job1 and job2 require a "fragile" tag (can only be served by vehicles with "fragile" tag) +//! - job3 and job4 have no tag requirements (can be served by any vehicle) +//! - vehicle_1 has both "fragile" and "hazmat" tags +//! - vehicle_2 has no tags (so cannot serve fragile jobs) +//! +//! Key points of implementation: +//! - using custom dimension (property) for jobs and vehicles with dedicated custom_dimension macro +//! - assigning tags to the jobs and vehicles by setting custom properties accordingly +//! - using the provided create_tags_feature which validates job-vehicle tag compatibility + +#[path = "./common/routing.rs"] +mod common; +use crate::common::define_routing_data; + +use std::collections::HashSet; +use std::sync::Arc; +use vrp_core::prelude::*; +use vrp_core::construction::features::{ + CapacityFeatureBuilder, TransportFeatureBuilder, create_tags_feature, + JobTagsDimension, VehicleTagsDimension, +}; +use vrp_core::models::problem::{JobIdDimension, VehicleIdDimension}; + +/// Specifies a CVRP problem variant with tags constraint: 4 delivery jobs (2 with "fragile" tag) and 2 vehicles +fn define_problem(goal: GoalContext, transport: Arc) -> GenericResult { + // create 4 jobs: 2 with "fragile" tag, 2 without + let single_jobs = (1..=4) + .map(|idx| { + SingleBuilder::default() + .id(format!("job{idx}").as_str()) + .demand(Demand::delivery(1)) + .dimension(|dimens| { + // jobs 1 and 2 have fragile requirement + if idx <= 2 { + let mut tags = HashSet::new(); + tags.insert("fragile".to_string()); + dimens.set_job_tags(tags); + } + }) + .location(idx)? + .build_as_job() + }) + .collect::, _>>()?; + + // create 2 vehicles + let vehicles = (1..=2) + .map(|idx| { + VehicleBuilder::default() + .id(format!("v{idx}").as_str()) + .add_detail( + VehicleDetailBuilder::default() + .set_start_location(0) + .set_end_location(0) + .build()?, + ) + .dimension(|dimens| { + // only vehicle 1 has "fragile" tag capability + if idx == 1 { + let mut tags = HashSet::new(); + tags.insert("fragile".to_string()); + tags.insert("hazmat".to_string()); + dimens.set_vehicle_tags(tags); + } + }) + // each vehicle has capacity=2, so it can serve at most 2 jobs + .capacity(SingleDimLoad::new(2)) + .build() + }) + .collect::, _>>()?; + + ProblemBuilder::default() + .add_jobs(single_jobs.into_iter()) + .add_vehicles(vehicles.into_iter()) + .with_goal(goal) + .with_transport_cost(transport) + .build() +} + +/// Defines CVRP variant with tags constraint as a goal of optimization. +fn define_goal(transport: Arc) -> GenericResult { + let minimize_unassigned = MinimizeUnassignedBuilder::new("min-unassigned").build()?; + let capacity_feature = CapacityFeatureBuilder::::new("capacity").build()?; + let transport_feature = TransportFeatureBuilder::new("min-distance") + .set_transport_cost(transport) + .set_time_constrained(false) + .build_minimize_distance()?; + // create our custom tags feature + let tags_feature = create_tags_feature("tags", ViolationCode::from(2))?; + + // configure goal of optimization + GoalContextBuilder::with_features(&[minimize_unassigned, transport_feature, capacity_feature, tags_feature])? + .build() +} + +fn main() -> GenericResult<()> { + let transport = Arc::new(define_routing_data()?); + + let goal = define_goal(transport.clone())?; + let problem = Arc::new(define_problem(goal, transport)?); + + let config = VrpConfigBuilder::new(problem.clone()).prebuild()?.with_max_generations(Some(10)).build()?; + + // run the VRP solver and get the best known solution + let solution = Solver::new(problem, config).solve()?; + + println!("\n--- Tags Constraint Example Results ---"); + println!("Total routes: {}", solution.routes.len()); + println!("Assigned jobs: {}", solution.routes.iter().map(|r| r.tour.job_count()).sum::()); + println!("Unassigned jobs: {}", solution.unassigned.len()); + + for (idx, route) in solution.routes.iter().enumerate() { + println!("\nRoute {}:", idx + 1); + println!(" Vehicle: {}", route.actor.vehicle.dimens.get_vehicle_id().unwrap_or(&"unknown".to_string())); + println!(" Jobs:"); + for job in route.tour.jobs() { + println!(" - {}", job.dimens().get_job_id().unwrap_or(&"unknown".to_string())); + } + } + + if !solution.unassigned.is_empty() { + println!("\nUnassigned jobs (failed tag matching):"); + for (job, _) in solution.unassigned.iter() { + println!(" - {}", job.dimens().get_job_id().unwrap_or(&"unknown".to_string())); + } + } + + println!("\nExpected behavior:"); + println!(" - job1 and job2 (fragile) should be assigned to vehicle_1 (has fragile tag)"); + println!(" - job3 and job4 (no tags) can be assigned to any vehicle"); + println!(" - vehicle_2 (no tags) cannot serve job1 or job2"); + + Ok(()) +} \ No newline at end of file From bc59a7cec6f92840a291efbcec325930cfafb060 Mon Sep 17 00:00:00 2001 From: krilar Date: Tue, 3 Feb 2026 14:58:28 +0100 Subject: [PATCH 3/5] feat: write suggested solution to file --- tags_constraint_solution.txt | 21 +++++++++++ vrp-core/examples/tags_constraint_example.rs | 37 ++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 tags_constraint_solution.txt diff --git a/tags_constraint_solution.txt b/tags_constraint_solution.txt new file mode 100644 index 000000000..3c77c6448 --- /dev/null +++ b/tags_constraint_solution.txt @@ -0,0 +1,21 @@ +--- Tags Constraint Example Results --- +Total routes: 2 +Assigned jobs: 4 +Unassigned jobs: 0 + +Route 1: + Vehicle: v1 + Jobs: + - job1 + - job2 + +Route 2: + Vehicle: v2 + Jobs: + - job4 + - job3 + +Expected behavior: + - job1 and job2 (fragile) should be assigned to vehicle_1 (has fragile tag) + - job3 and job4 (no tags) can be assigned to any vehicle + - vehicle_2 (no tags) cannot serve job1 or job2 diff --git a/vrp-core/examples/tags_constraint_example.rs b/vrp-core/examples/tags_constraint_example.rs index 1088b1eb7..47e58672f 100644 --- a/vrp-core/examples/tags_constraint_example.rs +++ b/vrp-core/examples/tags_constraint_example.rs @@ -18,6 +18,8 @@ use crate::common::define_routing_data; use std::collections::HashSet; use std::sync::Arc; +use std::fs::File; +use std::io::Write; use vrp_core::prelude::*; use vrp_core::construction::features::{ CapacityFeatureBuilder, TransportFeatureBuilder, create_tags_feature, @@ -107,10 +109,45 @@ fn main() -> GenericResult<()> { // run the VRP solver and get the best known solution let solution = Solver::new(problem, config).solve()?; + // Create output file + let mut output = File::create("tags_constraint_solution.txt")?; + + // Write results to file + writeln!(output, "--- Tags Constraint Example Results ---")?; + writeln!(output, "Total routes: {}", solution.routes.len())?; + writeln!(output, "Assigned jobs: {}", solution.routes.iter().map(|r| r.tour.job_count()).sum::())?; + writeln!(output, "Unassigned jobs: {}", solution.unassigned.len())?; + writeln!(output)?; + + for (idx, route) in solution.routes.iter().enumerate() { + writeln!(output, "Route {}:", idx + 1)?; + writeln!(output, " Vehicle: {}", route.actor.vehicle.dimens.get_vehicle_id().unwrap_or(&"unknown".to_string()))?; + writeln!(output, " Jobs:")?; + for job in route.tour.jobs() { + writeln!(output, " - {}", job.dimens().get_job_id().unwrap_or(&"unknown".to_string()))?; + } + writeln!(output)?; + } + + if !solution.unassigned.is_empty() { + writeln!(output, "Unassigned jobs (failed tag matching):")?; + for (job, _) in solution.unassigned.iter() { + writeln!(output, " - {}", job.dimens().get_job_id().unwrap_or(&"unknown".to_string()))?; + } + writeln!(output)?; + } + + writeln!(output, "Expected behavior:")?; + writeln!(output, " - job1 and job2 (fragile) should be assigned to vehicle_1 (has fragile tag)")?; + writeln!(output, " - job3 and job4 (no tags) can be assigned to any vehicle")?; + writeln!(output, " - vehicle_2 (no tags) cannot serve job1 or job2")?; + + // Also print to console println!("\n--- Tags Constraint Example Results ---"); println!("Total routes: {}", solution.routes.len()); println!("Assigned jobs: {}", solution.routes.iter().map(|r| r.tour.job_count()).sum::()); println!("Unassigned jobs: {}", solution.unassigned.len()); + println!("\nResults written to: tags_constraint_solution.txt"); for (idx, route) in solution.routes.iter().enumerate() { println!("\nRoute {}:", idx + 1); From 8dcdc1232b8a098d9faf8a4b16b9772dd2b49c23 Mon Sep 17 00:00:00 2001 From: krilar Date: Tue, 3 Feb 2026 15:40:30 +0100 Subject: [PATCH 4/5] feat: add another vehicle, v3 --- vrp-core/examples/tags_constraint_example.rs | 22 ++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/vrp-core/examples/tags_constraint_example.rs b/vrp-core/examples/tags_constraint_example.rs index 47e58672f..5d02d6806 100644 --- a/vrp-core/examples/tags_constraint_example.rs +++ b/vrp-core/examples/tags_constraint_example.rs @@ -74,9 +74,28 @@ fn define_problem(goal: GoalContext, transport: Arc) -> Gener }) .collect::, _>>()?; + let vehicle3 = VehicleBuilder::default() + .id("v3") + .add_detail( + VehicleDetailBuilder::default() + .set_start_location(0) + .set_end_location(0) + .build()?, + ) + .dimension(|dimens| { + // vehicle 3 has "hazmat" tag capability only + let mut tags = HashSet::new(); + tags.insert("hazmat".to_string()); + dimens.set_vehicle_tags(tags); + }) + // each vehicle has capacity=2, so it can serve at most 2 jobs + .capacity(SingleDimLoad::new(2)) + .build()?; + ProblemBuilder::default() .add_jobs(single_jobs.into_iter()) .add_vehicles(vehicles.into_iter()) + .add_vehicle(vehicle3) .with_goal(goal) .with_transport_cost(transport) .build() @@ -141,6 +160,7 @@ fn main() -> GenericResult<()> { writeln!(output, " - job1 and job2 (fragile) should be assigned to vehicle_1 (has fragile tag)")?; writeln!(output, " - job3 and job4 (no tags) can be assigned to any vehicle")?; writeln!(output, " - vehicle_2 (no tags) cannot serve job1 or job2")?; + writeln!(output, " - vehicle_3 (hazmat tag) cannot serve job1 or job2")?; // Also print to console println!("\n--- Tags Constraint Example Results ---"); @@ -169,6 +189,8 @@ fn main() -> GenericResult<()> { println!(" - job1 and job2 (fragile) should be assigned to vehicle_1 (has fragile tag)"); println!(" - job3 and job4 (no tags) can be assigned to any vehicle"); println!(" - vehicle_2 (no tags) cannot serve job1 or job2"); + println!(" - vehicle_3 (hazmat tag) cannot serve job1 or job2"); + Ok(()) } \ No newline at end of file From f73f0d7ee155d490be71e4e3ec9949fd42580294 Mon Sep 17 00:00:00 2001 From: krilar Date: Tue, 3 Feb 2026 15:45:28 +0100 Subject: [PATCH 5/5] feat: add another job to example --- vrp-core/examples/tags_constraint_example.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/vrp-core/examples/tags_constraint_example.rs b/vrp-core/examples/tags_constraint_example.rs index 5d02d6806..285cc0d01 100644 --- a/vrp-core/examples/tags_constraint_example.rs +++ b/vrp-core/examples/tags_constraint_example.rs @@ -30,7 +30,7 @@ use vrp_core::models::problem::{JobIdDimension, VehicleIdDimension}; /// Specifies a CVRP problem variant with tags constraint: 4 delivery jobs (2 with "fragile" tag) and 2 vehicles fn define_problem(goal: GoalContext, transport: Arc) -> GenericResult { // create 4 jobs: 2 with "fragile" tag, 2 without - let single_jobs = (1..=4) + let single_jobs = (1..=5) .map(|idx| { SingleBuilder::default() .id(format!("job{idx}").as_str()) @@ -42,6 +42,11 @@ fn define_problem(goal: GoalContext, transport: Arc) -> Gener tags.insert("fragile".to_string()); dimens.set_job_tags(tags); } + if idx == 5 { + let mut tags = HashSet::new(); + tags.insert("hazmat".to_string()); + dimens.set_job_tags(tags); + } }) .location(idx)? .build_as_job() @@ -159,6 +164,7 @@ fn main() -> GenericResult<()> { writeln!(output, "Expected behavior:")?; writeln!(output, " - job1 and job2 (fragile) should be assigned to vehicle_1 (has fragile tag)")?; writeln!(output, " - job3 and job4 (no tags) can be assigned to any vehicle")?; + writeln!(output, " - job5 (hazmat) can be assigned to vehicle_1 or vehicle_3 (have hazmat tag)")?; writeln!(output, " - vehicle_2 (no tags) cannot serve job1 or job2")?; writeln!(output, " - vehicle_3 (hazmat tag) cannot serve job1 or job2")?; @@ -188,6 +194,7 @@ fn main() -> GenericResult<()> { println!("\nExpected behavior:"); println!(" - job1 and job2 (fragile) should be assigned to vehicle_1 (has fragile tag)"); println!(" - job3 and job4 (no tags) can be assigned to any vehicle"); + println!(" - job5 (hazmat) can be assigned to vehicle_1 or vehicle_3 (have hazmat tag)"); println!(" - vehicle_2 (no tags) cannot serve job1 or job2"); println!(" - vehicle_3 (hazmat tag) cannot serve job1 or job2");