diff --git a/algobattle_problems/biclique/problem.py b/algobattle_problems/biclique/problem.py index 703d4dc..e9fce92 100644 --- a/algobattle_problems/biclique/problem.py +++ b/algobattle_problems/biclique/problem.py @@ -1,37 +1,36 @@ """The Biclique problem class.""" -from typing import ClassVar - -from algobattle.problem import UndirectedGraph, SolutionModel, ValidationError -from algobattle.util import u64 - - -class Biclique(UndirectedGraph): - """The Biclique problem class.""" - - name: ClassVar[str] = "Bipartite Clique" - min_size: ClassVar[int] = 5 - - class Solution(SolutionModel): - """A solution to a bipartite clique problem.""" - - direction: ClassVar = "maximize" - - s_1: set[u64] - s_2: set[u64] - - def validate_solution(self, instance: "Biclique") -> None: - edge_set = set(instance.edges) | set(edge[::-1] for edge in instance.edges) - super().validate_solution(instance) - if any(i >= instance.num_vertices for i in self.s_1 | self.s_2): - raise ValidationError("Solution contains vertices that aren't in the instance.") - if len(self.s_1.intersection(self.s_2)) != 0: - raise ValidationError("Solution contains vertex sets that aren't disjoint.") - if any((u, v) not in edge_set for u in self.s_1 for v in self.s_2): - raise ValidationError("The instance graph is missing an edge between the solution vertex sets.") - if any((u, v) in edge_set for u in self.s_1 for v in self.s_1) or any( - (u, v) in edge_set for u in self.s_2 for v in self.s_2 - ): - raise ValidationError("The solution is not a bipartite graph.") - - def score(self, instance: "Biclique") -> float: - return len(self.s_1) + len(self.s_2) +from algobattle.problem import Problem, UndirectedGraph, SolutionModel, ValidationError, maximize +from algobattle.util import u64, Role + + +class Solution(SolutionModel[UndirectedGraph]): + """A solution to a bipartite clique problem.""" + + s_1: set[u64] + s_2: set[u64] + + def validate_solution(self, instance: UndirectedGraph, role: Role) -> None: + edge_set = set(instance.edges) | set(edge[::-1] for edge in instance.edges) + super().validate_solution(instance, role) + if any(i >= instance.num_vertices for i in self.s_1 | self.s_2): + raise ValidationError("Solution contains vertices that aren't in the instance.") + if len(self.s_1.intersection(self.s_2)) != 0: + raise ValidationError("Solution contains vertex sets that aren't disjoint.") + if any((u, v) not in edge_set for u in self.s_1 for v in self.s_2): + raise ValidationError("The instance graph is missing an edge between the solution vertex sets.") + if any((u, v) in edge_set for u in self.s_1 for v in self.s_1) or any( + (u, v) in edge_set for u in self.s_2 for v in self.s_2 + ): + raise ValidationError("The solution is not a bipartite graph.") + + @maximize + def score(self, instance: UndirectedGraph, role: Role) -> float: + return len(self.s_1) + len(self.s_2) + + +Biclique = Problem( + name="Bipartite Clique", + instance_cls=UndirectedGraph, + solution_cls=Solution, + min_size=5, +) diff --git a/algobattle_problems/biclique/tests.py b/algobattle_problems/biclique/tests.py index 568f0cb..a42912a 100644 --- a/algobattle_problems/biclique/tests.py +++ b/algobattle_problems/biclique/tests.py @@ -1,7 +1,7 @@ """Tests for the biclique problem.""" import unittest -from algobattle_problems.biclique.problem import Biclique, ValidationError +from algobattle_problems.biclique.problem import UndirectedGraph, Solution, ValidationError, Role class Tests(unittest.TestCase): @@ -9,24 +9,24 @@ class Tests(unittest.TestCase): def test_vertices_exist(self): """Tests that only valid vertex indices are allowed.""" - graph = Biclique(num_vertices=10, edges=[(i, j) for i in range(10) for j in range(i)]) - sol = Biclique.Solution(s_1=set(), s_2={20}) + graph = UndirectedGraph(num_vertices=10, edges=[(i, j) for i in range(10) for j in range(i)]) + sol = Solution(s_1=set(), s_2={20}) with self.assertRaises(ValidationError): - sol.validate_solution(graph) + sol.validate_solution(graph, Role.generator) def test_edges_exist(self): """Tests that solutions that arent complete bicliques are not allowed.""" - graph = Biclique(num_vertices=10, edges=[]) - sol = Biclique.Solution(s_1={1}, s_2={2}) + graph = UndirectedGraph(num_vertices=10, edges=[]) + sol = Solution(s_1={1}, s_2={2}) with self.assertRaises(ValidationError): - sol.validate_solution(graph) + sol.validate_solution(graph, Role.generator) def test_edges_missing(self): """Asserts that solutions that aren't bipartite are not allowed.""" - graph = Biclique(num_vertices=10, edges=[(i, j) for i in range(10) for j in range(i)]) - sol = Biclique.Solution(s_1={1, 2}, s_2={3, 4}) + graph = UndirectedGraph(num_vertices=10, edges=[(i, j) for i in range(10) for j in range(i)]) + sol = Solution(s_1={1, 2}, s_2={3, 4}) with self.assertRaises(ValidationError): - sol.validate_solution(graph) + sol.validate_solution(graph, Role.generator) if __name__ == "__main__": diff --git a/algobattle_problems/c4subgraphiso/problem.py b/algobattle_problems/c4subgraphiso/problem.py index 2efc8c1..5749134 100644 --- a/algobattle_problems/c4subgraphiso/problem.py +++ b/algobattle_problems/c4subgraphiso/problem.py @@ -1,68 +1,66 @@ """The C4subgraphiso problem class.""" -from typing import ClassVar -from algobattle.problem import UndirectedGraph, SolutionModel, ValidationError -from algobattle.util import u64 +from algobattle.problem import Problem, UndirectedGraph, SolutionModel, ValidationError, maximize +from algobattle.util import u64, Role -class C4subgraphiso(UndirectedGraph): - """The C4subgraphiso problem class.""" +class Solution(SolutionModel[UndirectedGraph]): + """A solution to a Square Subgraph Isomorphism problem.""" - name: ClassVar[str] = "Square Subgraph Isomorphism" - min_size: ClassVar[int] = 4 + squares: set[tuple[u64, u64, u64, u64]] - class Solution(SolutionModel): - """A solution to a Square Subgraph Isomorphism problem.""" + def validate_solution(self, instance: UndirectedGraph, role: Role) -> None: + super().validate_solution(instance, role) + self._all_entries_bounded_in_size(instance) + self._all_squares_in_instance(instance) + self._all_squares_node_disjoint() + self._all_squares_induced(instance) - direction: ClassVar = "maximize" + def _all_entries_bounded_in_size(self, instance: UndirectedGraph) -> None: + for square in self.squares: + if any(node >= instance.num_vertices for node in square): + raise ValidationError("An element of the solution doesn't index an instance vertex.") - squares: set[tuple[u64, u64, u64, u64]] + def _all_squares_node_disjoint(self) -> None: + used_nodes = set() + for square in self.squares: + for node in square: + if node in used_nodes: + raise ValidationError("A square in the solution is not node-disjoint to at least one other square.") + used_nodes.add(node) - def validate_solution(self, instance: "C4subgraphiso") -> None: - super().validate_solution(instance) - self._all_entries_bounded_in_size(instance) - self._all_squares_in_instance(instance) - self._all_squares_node_disjoint() - self._all_squares_induced(instance) + def _all_squares_induced(self, instance: UndirectedGraph) -> None: + edge_set = set(instance.edges) + for square in self.squares: + # Edges between opposing nodes of a square would mean the square is not induced by its nodes + unwanted_edges = [ + (square[0], square[2]), + (square[2], square[0]), + (square[1], square[3]), + (square[3], square[1]), + ] + if any(edge in edge_set for edge in unwanted_edges): + raise ValidationError("A square in the solution is not induced in the instance.") - def _all_entries_bounded_in_size(self, instance: "C4subgraphiso") -> None: - for square in self.squares: - if any(node >= instance.num_vertices for node in square): - raise ValidationError("An element of the solution doesn't index an instance vertex.") + def _all_squares_in_instance(self, instance: UndirectedGraph) -> None: + edge_set = set(instance.edges) + for square in self.squares: + if ( + not ((square[0], square[1]) in edge_set or (square[1], square[0]) in edge_set) + or not ((square[1], square[2]) in edge_set or (square[2], square[1]) in edge_set) + or not ((square[2], square[3]) in edge_set or (square[3], square[2]) in edge_set) + or not ((square[3], square[0]) in edge_set or (square[0], square[3]) in edge_set) + ): + raise ValidationError("A square is not part of the instance.") - def _all_squares_node_disjoint(self) -> None: - used_nodes = set() - for square in self.squares: - for node in square: - if node in used_nodes: - raise ValidationError( - "A square in the solution is not node-disjoint to at least one other square." - ) - used_nodes.add(node) + @maximize + def score(self, instance: UndirectedGraph, role: Role) -> float: + return len(self.squares) - def _all_squares_induced(self, instance: "C4subgraphiso") -> None: - edge_set = set(instance.edges) - for square in self.squares: - # Edges between opposing nodes of a square would mean the square is not induced by its nodes - unwanted_edges = [ - (square[0], square[2]), - (square[2], square[0]), - (square[1], square[3]), - (square[3], square[1]), - ] - if any(edge in edge_set for edge in unwanted_edges): - raise ValidationError("A square in the solution is not induced in the instance.") - def _all_squares_in_instance(self, instance: "C4subgraphiso") -> None: - edge_set = set(instance.edges) - for square in self.squares: - if ( - not ((square[0], square[1]) in edge_set or (square[1], square[0]) in edge_set) - or not ((square[1], square[2]) in edge_set or (square[2], square[1]) in edge_set) - or not ((square[2], square[3]) in edge_set or (square[3], square[2]) in edge_set) - or not ((square[3], square[0]) in edge_set or (square[0], square[3]) in edge_set) - ): - raise ValidationError("A square is not part of the instance.") - - def score(self, instance: "C4subgraphiso") -> float: - return len(self.squares) +C4subgraphiso = Problem( + name="Square Subgraph Isomorphism", + instance_cls=UndirectedGraph, + solution_cls=Solution, + min_size=4, +) diff --git a/algobattle_problems/c4subgraphiso/tests.py b/algobattle_problems/c4subgraphiso/tests.py index f21ed71..a3631fb 100644 --- a/algobattle_problems/c4subgraphiso/tests.py +++ b/algobattle_problems/c4subgraphiso/tests.py @@ -3,7 +3,7 @@ from pydantic import ValidationError as PydanticValidationError -from algobattle_problems.c4subgraphiso.problem import C4subgraphiso, ValidationError +from algobattle_problems.c4subgraphiso.problem import UndirectedGraph, Solution, ValidationError, Role class Tests(unittest.TestCase): @@ -12,7 +12,7 @@ class Tests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: super().setUpClass() - cls.instance = C4subgraphiso( + cls.instance = UndirectedGraph( num_vertices=10, edges=[ (0, 1), @@ -35,7 +35,7 @@ def setUpClass(cls) -> None: def test_no_duplicate_squares(self): with self.assertRaises(PydanticValidationError): - C4subgraphiso.parse_obj( + UndirectedGraph.parse_obj( { "squares": { (0, 1, 2, 3), @@ -45,29 +45,29 @@ def test_no_duplicate_squares(self): ) def test_vertex_too_big(self): - solution = C4subgraphiso.Solution(squares={(0, 1, 2, 10)}) + solution = Solution(squares={(0, 1, 2, 10)}) with self.assertRaises(ValidationError): - solution.validate_solution(self.instance) + solution.validate_solution(self.instance, Role.generator) def test_edge_missing(self): - solution = C4subgraphiso.Solution(squares={(2, 3, 4, 5)}) + solution = Solution(squares={(2, 3, 4, 5)}) with self.assertRaises(ValidationError): - solution.validate_solution(self.instance) + solution.validate_solution(self.instance, Role.generator) def test_additional_edge(self): - solution = C4subgraphiso.Solution(squares={(1, 2, 4, 8)}) + solution = Solution(squares={(1, 2, 4, 8)}) with self.assertRaises(ValidationError): - solution.validate_solution(self.instance) + solution.validate_solution(self.instance, Role.generator) def test_score(self): - solution = C4subgraphiso.Solution(squares={(0, 1, 8, 9), (4, 5, 6, 7)}) - solution.validate_solution(self.instance) - self.assertEqual(solution.score(self.instance), 2) + solution = Solution(squares={(0, 1, 8, 9), (4, 5, 6, 7)}) + solution.validate_solution(self.instance, Role.generator) + self.assertEqual(solution.score(self.instance, Role.solver), 2) def test_squares_disjoin(self): - solution = C4subgraphiso.Solution(squares={(0, 1, 2, 3), (0, 1, 8, 9)}) + solution = Solution(squares={(0, 1, 2, 3), (0, 1, 8, 9)}) with self.assertRaises(ValidationError): - solution.validate_solution(self.instance) + solution.validate_solution(self.instance, Role.generator) if __name__ == "__main__": diff --git a/algobattle_problems/clusterediting/problem.py b/algobattle_problems/clusterediting/problem.py index 29b2326..037682f 100644 --- a/algobattle_problems/clusterediting/problem.py +++ b/algobattle_problems/clusterediting/problem.py @@ -1,50 +1,50 @@ """The Clusterediting problem class.""" from collections import defaultdict from itertools import combinations -from typing import ClassVar -from algobattle.problem import UndirectedGraph, SolutionModel, ValidationError -from algobattle.util import u64 +from algobattle.problem import Problem, UndirectedGraph, SolutionModel, ValidationError, minimize +from algobattle.util import u64, Role -class Clusterediting(UndirectedGraph): - """The Clusterediting problem class.""" +class Solution(SolutionModel[UndirectedGraph]): + """A solution to a Cluster Editing problem.""" - name: ClassVar[str] = "Cluster Editing" - min_size: ClassVar[int] = 4 + add: set[tuple[u64, u64]] + delete: set[tuple[u64, u64]] - class Solution(SolutionModel): - """A solution to a Cluster Editing problem.""" + def validate_solution(self, instance: UndirectedGraph, role: Role) -> None: + edge_set = set(instance.edges) - direction: ClassVar = "minimize" + # Apply modifications to graph + for edge in self.add: + if edge[::-1] not in edge_set: + edge_set.add(edge) + for edge in self.delete: + if edge in edge_set: + edge_set.remove(edge) + elif (edge[1], edge[0]) in edge_set: + edge_set.remove((edge[1], edge[0])) + else: + raise ValidationError("Solution contains edge not found in instance.") - add: set[tuple[u64, u64]] - delete: set[tuple[u64, u64]] + neighbors: defaultdict[int, set[int]] = defaultdict(set) + for u, v in edge_set: + neighbors[u].add(v) + neighbors[v].add(u) - def validate_solution(self, instance: "Clusterediting") -> None: - edge_set = set(instance.edges) + for u in range(instance.num_vertices): + for v, w in combinations(neighbors[u], 2): + if not (v, w) in edge_set and not (w, v) in edge_set: + raise ValidationError("The solution does not transform the graph into a cluster.") - # Apply modifications to graph - for edge in self.add: - if edge[::-1] not in edge_set: - edge_set.add(edge) - for edge in self.delete: - if edge in edge_set: - edge_set.remove(edge) - elif (edge[1], edge[0]) in edge_set: - edge_set.remove((edge[1], edge[0])) - else: - raise ValidationError("Solution contains edge not found in instance.") + @minimize + def score(self, instance: UndirectedGraph, role: Role) -> float: + return len(self.add) + len(self.delete) - neighbors: defaultdict[int, set[int]] = defaultdict(set) - for u, v in edge_set: - neighbors[u].add(v) - neighbors[v].add(u) - for u in range(instance.num_vertices): - for v, w in combinations(neighbors[u], 2): - if not (v, w) in edge_set and not (w, v) in edge_set: - raise ValidationError("The solution does not transform the graph into a cluster.") - - def score(self, instance: "Clusterediting") -> float: - return len(self.add) + len(self.delete) +Clusterediting = Problem( + name="Cluster Editing", + min_size=4, + instance_cls=UndirectedGraph, + solution_cls=Solution, +) diff --git a/algobattle_problems/clusterediting/tests.py b/algobattle_problems/clusterediting/tests.py index 4d82598..f66dab5 100644 --- a/algobattle_problems/clusterediting/tests.py +++ b/algobattle_problems/clusterediting/tests.py @@ -1,10 +1,7 @@ """Tests for the clusterediting problem.""" import unittest -from algobattle_problems.clusterediting.problem import Clusterediting, ValidationError - - -Solution = Clusterediting.Solution +from algobattle_problems.clusterediting.problem import UndirectedGraph, Solution, ValidationError, Role class Tests(unittest.TestCase): @@ -12,7 +9,7 @@ class Tests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: - cls.instance = Clusterediting( + cls.instance = UndirectedGraph( num_vertices=10, edges=[ (0, 2), @@ -32,37 +29,37 @@ def setUpClass(cls) -> None: def test_delete_nonexisting_edge(self): solution = Solution(add=set(), delete={(0, 1)}) with self.assertRaises(ValidationError): - solution.validate_solution(self.instance) + solution.validate_solution(self.instance, Role.generator) def test_delete_extra_edge(self): - instance = Clusterediting(num_vertices=4, edges=[(0, 1), (1, 2), (2, 0), (0, 3)]) + instance = UndirectedGraph(num_vertices=4, edges=[(0, 1), (1, 2), (2, 0), (0, 3)]) solution = Solution(add=set(), delete={(0, 3)}) - solution.validate_solution(instance) + solution.validate_solution(instance, Role.generator) def test_delete_and_add_edge(self): - instance = Clusterediting(num_vertices=4, edges=[(1, 2), (2, 0), (0, 3)]) + instance = UndirectedGraph(num_vertices=4, edges=[(1, 2), (2, 0), (0, 3)]) solution = Solution(add={(0, 1)}, delete={(0, 3)}) - solution.validate_solution(instance) + solution.validate_solution(instance, Role.generator) def test_add_edge_reverse(self): solution = Solution(add={(2, 0)}, delete=set()) with self.assertRaises(ValidationError): - solution.validate_solution(self.instance) + solution.validate_solution(self.instance, Role.generator) def test_delete_edge_reverse(self): - instance = Clusterediting(num_vertices=3, edges=[(0, 1), (1, 2)]) + instance = UndirectedGraph(num_vertices=3, edges=[(0, 1), (1, 2)]) solution = Solution(add=set(), delete={(1, 0)}) - solution.validate_solution(instance) + solution.validate_solution(instance, Role.generator) def test_score(self): solution = Solution(add={(0, 1), (5, 8)}, delete={(4, 8), (7, 1), (0, 2), (2, 5)}) - solution.validate_solution(self.instance) - self.assertEqual(solution.score(self.instance), 6) + solution.validate_solution(self.instance, Role.generator) + self.assertEqual(solution.score(self.instance, Role.solver), 1 / 6) def test_solution_doesnt_triangulate(self): solution = Solution(add={(0, 1), (5, 8)}, delete={(7, 1), (0, 2), (2, 5)}) with self.assertRaises(ValidationError): - solution.validate_solution(self.instance) + solution.validate_solution(self.instance, Role.generator) if __name__ == "__main__": diff --git a/algobattle_problems/domset/problem.py b/algobattle_problems/domset/problem.py index 2e62226..0653b2a 100644 --- a/algobattle_problems/domset/problem.py +++ b/algobattle_problems/domset/problem.py @@ -1,38 +1,37 @@ """The Clusterediting problem class.""" -from typing import ClassVar - -from algobattle.problem import UndirectedGraph, SolutionModel, ValidationError -from algobattle.util import u64 - - -class Domset(UndirectedGraph): - """The DomSet problem class.""" - - name: ClassVar[str] = "Dominating Set" - min_size: ClassVar[int] = 2 - - class Solution(SolutionModel): - """A solution to a Dominating Set problem.""" - - domset: set[u64] - - direction: ClassVar = "minimize" - - def validate_solution(self, instance: "Domset") -> None: - if any(u >= instance.num_vertices for u in self.domset): - raise ValidationError("A number in the domset is too large to be a vertex") - - dominated = set(self.domset) - for u, v in instance.edges: - if u in self.domset: - dominated.add(v) - elif v in self.domset: - dominated.add(u) - if len(dominated) != instance.num_vertices: - raise ValidationError( - "Not every vertex is dominated.", - detail=f"{instance.num_vertices - len(dominated)} vertices are not dominated", - ) - - def score(self, instance: "Domset") -> float: - return len(self.domset) +from algobattle.problem import Problem, UndirectedGraph, SolutionModel, ValidationError, minimize +from algobattle.util import u64, Role + + +class Solution(SolutionModel[UndirectedGraph]): + """A solution to a Dominating Set problem.""" + + domset: set[u64] + + def validate_solution(self, instance: UndirectedGraph, role: Role) -> None: + if any(u >= instance.num_vertices for u in self.domset): + raise ValidationError("A number in the domset is too large to be a vertex") + + dominated = set(self.domset) + for u, v in instance.edges: + if u in self.domset: + dominated.add(v) + elif v in self.domset: + dominated.add(u) + if len(dominated) != instance.num_vertices: + raise ValidationError( + "Not every vertex is dominated.", + detail=f"{instance.num_vertices - len(dominated)} vertices are not dominated", + ) + + @minimize + def score(self, instance: UndirectedGraph, role: Role) -> float: + return len(self.domset) + + +Domset = Problem( + name="Dominating Set", + min_size=2, + instance_cls=UndirectedGraph, + solution_cls=Solution, +) diff --git a/algobattle_problems/domset/tests.py b/algobattle_problems/domset/tests.py index 51e816e..04b8fdc 100644 --- a/algobattle_problems/domset/tests.py +++ b/algobattle_problems/domset/tests.py @@ -1,7 +1,7 @@ """Tests for the DomSet problem.""" import unittest -from algobattle_problems.domset.problem import Domset, ValidationError +from algobattle_problems.domset.problem import Domset, UndirectedGraph, Solution, ValidationError, Role class Tests(unittest.TestCase): @@ -9,7 +9,7 @@ class Tests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: - cls.instance = Domset( + cls.instance = UndirectedGraph( num_vertices=5, edges=[ (0, 1), @@ -20,20 +20,22 @@ def setUpClass(cls) -> None: ) def test_basic_validate(self): - solution = Domset.Solution(domset={1, 3, 4}) - solution.validate_solution(self.instance) + solution = Solution(domset={1, 3, 4}) + solution.validate_solution(self.instance, Role.generator) def test_validate_missing_vertex(self): - solution = Domset.Solution(domset={1, 3}) + solution = Solution(domset={1, 3}) with self.assertRaises(ValidationError): - solution.validate_solution(self.instance) + solution.validate_solution(self.instance, Role.generator) def test_score(self): - bad_solution = Domset.Solution(domset={0, 1, 2, 3, 4}) - good_solution = Domset.Solution(domset={1, 3, 4}) - self.assertEqual(bad_solution.score(self.instance), 5) - self.assertEqual(good_solution.score(self.instance), 3) - self.assertEqual(self.instance.score(bad_solution, good_solution), 0.6) + bad_solution = Solution(domset={0, 1, 2, 3, 4}) + good_solution = Solution(domset={1, 3, 4}) + self.assertAlmostEqual(bad_solution.score(self.instance, Role.solver), 1 / 5) + self.assertAlmostEqual(good_solution.score(self.instance, Role.generator), 1 / 3) + self.assertAlmostEqual( + Domset.score(self.instance, generator_solution=good_solution, solver_solution=bad_solution), 0.6 + ) if __name__ == "__main__": diff --git a/algobattle_problems/hikers/problem.py b/algobattle_problems/hikers/problem.py index a408a0e..1ce1e21 100644 --- a/algobattle_problems/hikers/problem.py +++ b/algobattle_problems/hikers/problem.py @@ -1,16 +1,11 @@ """The Hikers problem class.""" -from typing import ClassVar +from algobattle.problem import Problem, InstanceModel, SolutionModel, ValidationError, maximize +from algobattle.util import u64, Role -from algobattle.problem import ProblemModel, SolutionModel, ValidationError -from algobattle.util import u64 - -class Hikers(ProblemModel): +class HikersInstance(InstanceModel): """The Tsptimewindows problem class.""" - name: ClassVar[str] = "Hikers" - min_size: ClassVar[int] = 5 - hikers: list[tuple[u64, u64]] @property @@ -22,25 +17,33 @@ def validate_instance(self) -> None: if any(min_size > max_size for min_size, max_size in self.hikers): raise ValidationError("One hiker's minimum group size is larger than their maximum group size.") - class Solution(SolutionModel): - """A solution to a Hikers problem.""" - direction: ClassVar = "maximize" +class Solution(SolutionModel[HikersInstance]): + """A solution to a Hikers problem.""" + + assignments: dict[u64, u64] + + def validate_solution(self, instance: HikersInstance, role: Role) -> None: + if any(hiker >= len(instance.hikers) for hiker in self.assignments): + raise ValidationError("Solution contains hiker that is not in the instance.") - assignments: dict[u64, u64] + group_sizes: dict[int, int] = {} + for group in self.assignments.values(): + group_sizes[group] = group_sizes.get(group, 0) + 1 - def validate_solution(self, instance: "Hikers") -> None: - if any(hiker >= len(instance.hikers) for hiker in self.assignments): - raise ValidationError("Solution contains hiker that is not in the instance.") + for hiker, group in self.assignments.items(): + min_size, max_size = instance.hikers[hiker] + if not (min_size <= group_sizes[group] <= max_size): + raise ValidationError("A Hiker is not happy with their assignment!") - group_sizes: dict[int, int] = {} - for group in self.assignments.values(): - group_sizes[group] = group_sizes.get(group, 0) + 1 + @maximize + def score(self, instance: HikersInstance, role: Role) -> float: + return len(self.assignments) - for hiker, group in self.assignments.items(): - min_size, max_size = instance.hikers[hiker] - if not (min_size <= group_sizes[group] <= max_size): - raise ValidationError("A Hiker is not happy with their assignment!") - def score(self, instance: "Hikers") -> float: - return len(self.assignments) +Hikers = Problem( + name="Hikers", + min_size=5, + instance_cls=HikersInstance, + solution_cls=Solution, +) diff --git a/algobattle_problems/hikers/tests.py b/algobattle_problems/hikers/tests.py index 5446d62..cdb858b 100644 --- a/algobattle_problems/hikers/tests.py +++ b/algobattle_problems/hikers/tests.py @@ -1,7 +1,7 @@ """Tests for the hikers problem.""" import unittest -from algobattle_problems.hikers.problem import Hikers, ValidationError +from algobattle_problems.hikers.problem import HikersInstance, Solution, ValidationError, Role class Tests(unittest.TestCase): @@ -9,7 +9,7 @@ class Tests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: - cls.instance = Hikers( + cls.instance = HikersInstance( hikers=[ (1, 3), (10, 12), @@ -20,11 +20,11 @@ def setUpClass(cls) -> None: ) def test_solution_empty(self): - solution = Hikers.Solution(assignments={}) - solution.validate_solution(self.instance) + solution = Solution(assignments={}) + solution.validate_solution(self.instance, Role.generator) def test_solution_correct(self): - solution = Hikers.Solution( + solution = Solution( assignments={ 2: 1, 0: 2, @@ -32,17 +32,17 @@ def test_solution_correct(self): 4: 2, } ) - solution.validate_solution(self.instance) + solution.validate_solution(self.instance, Role.generator) def test_solution_wrong_hiker(self): - solution = Hikers.Solution(assignments={10: 1}) + solution = Solution(assignments={10: 1}) with self.assertRaises(ValidationError): - solution.validate_solution(self.instance) + solution.validate_solution(self.instance, Role.generator) def test_solution_hiker_unhappy(self): - solution = Hikers.Solution(assignments={1: 1}) + solution = Solution(assignments={1: 1}) with self.assertRaises(ValidationError): - solution.validate_solution(self.instance) + solution.validate_solution(self.instance, Role.generator) if __name__ == "__main__": diff --git a/algobattle_problems/longestpathboundedfvs/problem.py b/algobattle_problems/longestpathboundedfvs/problem.py index ada7718..a544c1c 100644 --- a/algobattle_problems/longestpathboundedfvs/problem.py +++ b/algobattle_problems/longestpathboundedfvs/problem.py @@ -1,21 +1,18 @@ """The Longestpathboundedfvs problem class.""" from math import sqrt -from typing import ClassVar -from pydantic import Field +from pydantic import Field from networkx import Graph from networkx.algorithms.tree.recognition import is_forest from networkx.classes.function import is_empty -from algobattle.problem import UndirectedGraph, SolutionModel, ValidationError -from algobattle.util import u64 +from algobattle.problem import Problem, UndirectedGraph, SolutionModel, ValidationError, maximize +from algobattle.util import u64, Role -class Longestpathboundedfvs(UndirectedGraph): +class Instance(UndirectedGraph): """The Longestpathboundedfvs problem class.""" - name: ClassVar[str] = "Longest Path with Bounded Feedback Vertex Set" - min_size: ClassVar[int] = 3 fvs: set[u64] = Field(hidden="solver") def validate_instance(self) -> None: @@ -37,31 +34,39 @@ def valid_fvs_on_input(self) -> bool: g.remove_node(node) return is_empty(g) or is_forest(g) - class Solution(SolutionModel): - """A solution to a Longest Path with Bounded Feedback Vertex Set problem.""" - path: list[u64] +class Solution(SolutionModel[Instance]): + """A solution to a Longest Path with Bounded Feedback Vertex Set problem.""" + + path: list[u64] - direction: ClassVar = "maximize" + def validate_solution(self, instance: Instance, role: Role) -> None: + if not self._nodes_are_walk(instance): + raise ValidationError("The given path is not a walk in the instance graph.") + if not self._no_revisited_nodes(): + raise ValidationError("The given path contains repeated nodes.") + + def _nodes_are_walk(self, instance) -> bool: + edge_set = set(instance.edges) + g = Graph() + for edge in edge_set: + g.add_edge(edge[0], edge[1]) + for i in range(len(self.path) - 1): + if not g.has_edge(self.path[i], self.path[i + 1]): + return False + return True - def validate_solution(self, instance: "Longestpathboundedfvs") -> None: - if not self._nodes_are_walk(instance): - raise ValidationError("The given path is not a walk in the instance graph.") - if not self._no_revisited_nodes(): - raise ValidationError("The given path contains repeated nodes.") + def _no_revisited_nodes(self) -> bool: + return len(self.path) == len(set(self.path)) - def _nodes_are_walk(self, instance) -> bool: - edge_set = set(instance.edges) - g = Graph() - for edge in edge_set: - g.add_edge(edge[0], edge[1]) - for i in range(len(self.path) - 1): - if not g.has_edge(self.path[i], self.path[i + 1]): - return False - return True + @maximize + def score(self, instance: Instance, role: Role) -> float: + return len(self.path) - def _no_revisited_nodes(self) -> bool: - return len(self.path) == len(set(self.path)) - def score(self, instance: "Longestpathboundedfvs") -> float: - return len(self.path) +Longestpathboundedfvs = Problem( + name="Longest Path with Bounded Feedback Vertex Set", + min_size=3, + instance_cls=Instance, + solution_cls=Solution, +) diff --git a/algobattle_problems/oscm3/problem.py b/algobattle_problems/oscm3/problem.py index 6a922b4..8436df5 100644 --- a/algobattle_problems/oscm3/problem.py +++ b/algobattle_problems/oscm3/problem.py @@ -1,16 +1,11 @@ """The OSCM3 problem class.""" -from typing import ClassVar +from algobattle.problem import Problem, InstanceModel, SolutionModel, ValidationError, minimize +from algobattle.util import u64, Role -from algobattle.problem import ProblemModel, SolutionModel, ValidationError -from algobattle.util import u64 - -class OSCM3(ProblemModel): +class Instance(InstanceModel): """The OSCM3 problem class.""" - name: ClassVar[str] = "One-Sided Crossing Minimization-3" - min_size: ClassVar[int] = 1 - neighbors: dict[u64, set[u64]] @property @@ -29,24 +24,32 @@ def validate_instance(self) -> None: for u in range(size): self.neighbors.setdefault(u, set()) - class Solution(SolutionModel): - """A solution to a One-Sided Crossing Minimization-3 problem.""" - direction: ClassVar = "minimize" +class Solution(SolutionModel[Instance]): + """A solution to a One-Sided Crossing Minimization-3 problem.""" + + vertex_order: list[u64] + + def validate_solution(self, instance: Instance, role: Role) -> None: + if any(not 0 <= i < instance.size for i in self.vertex_order): + raise ValidationError("An element of the solution is not in the permitted range.") + if len(self.vertex_order) != len(set(self.vertex_order)): + raise ValidationError("The solution contains duplicate numbers.") + if len(self.vertex_order) != instance.size: + raise ValidationError("The solution does not order the whole instance.") - vertex_order: list[u64] + @minimize + def score(self, instance: Instance, role: Role) -> float: + score = 0 + for position, vertex in enumerate(self.vertex_order): + for i in instance.neighbors[vertex]: + score += sum(j < i for other in self.vertex_order[position:] for j in instance.neighbors[other]) + return score - def validate_solution(self, instance: "OSCM3") -> None: - if any(not 0 <= i < instance.size for i in self.vertex_order): - raise ValidationError("An element of the solution is not in the permitted range.") - if len(self.vertex_order) != len(set(self.vertex_order)): - raise ValidationError("The solution contains duplicate numbers.") - if len(self.vertex_order) != instance.size: - raise ValidationError("The solution does not order the whole instance.") - def score(self, instance: "OSCM3") -> float: - score = 0 - for position, vertex in enumerate(self.vertex_order): - for i in instance.neighbors[vertex]: - score += sum(j < i for other in self.vertex_order[position:] for j in instance.neighbors[other]) - return score +OSCM3 = Problem( + name="One-Sided Crossing Minimization-3", + min_size=1, + instance_cls=Instance, + solution_cls=Solution, +) diff --git a/algobattle_problems/oscm3/tests.py b/algobattle_problems/oscm3/tests.py index 9d5b1f1..7ae46f4 100644 --- a/algobattle_problems/oscm3/tests.py +++ b/algobattle_problems/oscm3/tests.py @@ -1,7 +1,7 @@ """Tests for the OSCM3 problem.""" import unittest -from algobattle_problems.oscm3.problem import OSCM3, ValidationError +from algobattle_problems.oscm3.problem import Instance, Solution, ValidationError, Role class Tests(unittest.TestCase): @@ -9,7 +9,7 @@ class Tests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: - cls.instance = OSCM3( + cls.instance = Instance( neighbors={ 0: {1, 2}, 1: {0, 1, 2}, @@ -19,20 +19,20 @@ def setUpClass(cls) -> None: def test_too_many_neighbors(self): with self.assertRaises(ValidationError): - instance = OSCM3(neighbors={0: {0, 1, 2, 3}, 3: set()}) + instance = Instance(neighbors={0: {0, 1, 2, 3}, 3: set()}) instance.validate_instance() def test_solution_not_permutation(self): with self.assertRaises(ValidationError): - OSCM3.Solution(vertex_order=[0, 0, 0]).validate_solution(self.instance) + Solution(vertex_order=[0, 0, 0]).validate_solution(self.instance, Role.generator) def test_solution_too_small(self): with self.assertRaises(ValidationError): - OSCM3.Solution(vertex_order=[0, 1]).validate_solution(self.instance) + Solution(vertex_order=[0, 1]).validate_solution(self.instance, Role.generator) def test_solution_wrong_indices(self): with self.assertRaises(ValidationError): - OSCM3.Solution(vertex_order=[1, 2, 3]).validate_solution(self.instance) + Solution(vertex_order=[1, 2, 3]).validate_solution(self.instance, Role.generator) if __name__ == "__main__": diff --git a/algobattle_problems/pairsum/problem.py b/algobattle_problems/pairsum/problem.py index b9d0b1a..65a46e7 100644 --- a/algobattle_problems/pairsum/problem.py +++ b/algobattle_problems/pairsum/problem.py @@ -1,35 +1,40 @@ """Main module of the Pairsum problem.""" -from typing import ClassVar from pydantic import Field -from algobattle.problem import ProblemModel, SolutionModel, ValidationError -from algobattle.util import u64 +from algobattle.problem import Problem, InstanceModel, SolutionModel, ValidationError +from algobattle.util import u64, Role -class Pairsum(ProblemModel): - """The Pairsum problem class.""" +class Instance(InstanceModel): + """An instance of a Pairsum problem.""" - name: ClassVar[str] = "Pairsum" - min_size: ClassVar[int] = 4 - - numbers: list[int] = Field(min_items=min_size, ge=0, le=2**63 - 1) + numbers: list[int] = Field(min_items=4, ge=0, le=2**63 - 1) @property def size(self) -> int: return len(self.numbers) - class Solution(SolutionModel): - """A solution to a Pairsum problem.""" - - indices: tuple[u64, u64, u64, u64] - - def validate_solution(self, instance: "Pairsum") -> None: - super().validate_solution(instance) - if any(i >= len(instance.numbers) for i in self.indices): - raise ValidationError("Solution index is out of range.") - if len(self.indices) != len(set(self.indices)): - raise ValidationError("Solution contains duplicate indices.") - first = instance.numbers[self.indices[0]] + instance.numbers[self.indices[1]] - second = instance.numbers[self.indices[2]] + instance.numbers[self.indices[3]] - if first != second: - raise ValidationError("Solution elements don't have the same sum.") + +class Solution(SolutionModel[Instance]): + """A solution to a Pairsum problem.""" + + indices: tuple[u64, u64, u64, u64] + + def validate_solution(self, instance: Instance, role: Role) -> None: + super().validate_solution(instance, role) + if any(i >= len(instance.numbers) for i in self.indices): + raise ValidationError("Solution index is out of range.") + if len(self.indices) != len(set(self.indices)): + raise ValidationError("Solution contains duplicate indices.") + first = instance.numbers[self.indices[0]] + instance.numbers[self.indices[1]] + second = instance.numbers[self.indices[2]] + instance.numbers[self.indices[3]] + if first != second: + raise ValidationError("Solution elements don't have the same sum.") + + +Pairsum = Problem( + name="Pairsum", + min_size=4, + instance_cls=Instance, + solution_cls=Solution, +) diff --git a/algobattle_problems/pairsum/tests.py b/algobattle_problems/pairsum/tests.py index a2269e0..3d987ee 100644 --- a/algobattle_problems/pairsum/tests.py +++ b/algobattle_problems/pairsum/tests.py @@ -1,7 +1,7 @@ """Tests for the Pairsum problem.""" import unittest -from algobattle_problems.pairsum.problem import Pairsum, ValidationError +from algobattle_problems.pairsum.problem import Instance, Solution, ValidationError, Role class Tests(unittest.TestCase): @@ -9,24 +9,24 @@ class Tests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: - cls.instance = Pairsum(numbers=[1, 2, 3, 4]) + cls.instance = Instance(numbers=[1, 2, 3, 4]) def test_size(self): self.assertEqual(self.instance.size, 4) - self.assertEqual(Pairsum(numbers=[1, 2, 3, 4, 5, 6]).size, 6) - self.assertEqual(Pairsum(numbers=list(range(17))).size, 17) + self.assertEqual(Instance(numbers=[1, 2, 3, 4, 5, 6]).size, 6) + self.assertEqual(Instance(numbers=list(range(17))).size, 17) def test_solution_wrong_indices(self): with self.assertRaises(ValidationError): - Pairsum.Solution(indices=(100, 101, 102, 103)).validate_solution(self.instance) + Solution(indices=(100, 101, 102, 103)).validate_solution(self.instance, Role.generator) def test_solution_duplicate_index(self): with self.assertRaises(ValidationError): - Pairsum.Solution(indices=(0, 0, 1, 2)).validate_solution(self.instance) + Solution(indices=(0, 0, 1, 2)).validate_solution(self.instance, Role.generator) def test_solution_wrong_sum(self): with self.assertRaises(ValidationError): - Pairsum.Solution(indices=(0, 1, 2, 3)).validate_solution(self.instance) + Solution(indices=(0, 1, 2, 3)).validate_solution(self.instance, Role.generator) if __name__ == "__main__": diff --git a/algobattle_problems/pathpacking/problem.py b/algobattle_problems/pathpacking/problem.py index 946bd05..6215f40 100644 --- a/algobattle_problems/pathpacking/problem.py +++ b/algobattle_problems/pathpacking/problem.py @@ -1,43 +1,43 @@ """The PathPacking problem class.""" -from typing import ClassVar -from algobattle.problem import UndirectedGraph, SolutionModel, ValidationError -from algobattle.util import u64 +from algobattle.problem import Problem, UndirectedGraph, SolutionModel, ValidationError, maximize +from algobattle.util import u64, Role -class Pathpacking(UndirectedGraph): - """The Path Packing problem class.""" +class Solution(SolutionModel[UndirectedGraph]): + """A solution to a Path Packing problem.""" - name: ClassVar[str] = "P_3 Path Packing" - min_size: ClassVar[int] = 3 + paths: set[tuple[u64, u64, u64]] - class Solution(SolutionModel): - """A solution to a Path Packing problem.""" + def validate_solution(self, instance: UndirectedGraph, role: Role) -> None: + if not self.all_paths_disjoint(self.paths): + raise ValidationError("Not all paths in the solution are node-disjoint.") + for path in self.paths: + if any(entry >= instance.num_vertices for entry in path): + raise ValidationError("Solution contains index that is not a valid vertex.") + if not self.path_in_instance(path, instance): + raise ValidationError("Solution contains path that is not part of the instance.") - direction: ClassVar = "maximize" + def all_paths_disjoint(self, paths: set[tuple[int, int, int]]): + """Check if all paths of the instance are node-disjoint.""" + used_nodes = {u for path in paths for u in path} + return len(paths) * 3 == len(used_nodes) - paths: set[tuple[u64, u64, u64]] + def path_in_instance(self, path: tuple[int, int, int], instance: UndirectedGraph) -> bool: + """Check if a given path is part of the given instance.""" + edge_set = set(instance.edges) + edge_set |= {(v, u) for u, v in edge_set} + u, v, w = path + return (u, v) in edge_set and (v, w) in edge_set - def validate_solution(self, instance: "Pathpacking") -> None: - if not self.all_paths_disjoint(self.paths): - raise ValidationError("Not all paths in the solution are node-disjoint.") - for path in self.paths: - if any(entry >= instance.num_vertices for entry in path): - raise ValidationError("Solution contains index that is not a valid vertex.") - if not self.path_in_instance(path, instance): - raise ValidationError("Solution contains path that is not part of the instance.") + @maximize + def score(self, instance: UndirectedGraph, role: Role) -> float: + return len(self.paths) - def all_paths_disjoint(self, paths: set[tuple[int, int, int]]): - """Check if all paths of the instance are node-disjoint.""" - used_nodes = {u for path in paths for u in path} - return len(paths) * 3 == len(used_nodes) - def path_in_instance(self, path: tuple[int, int, int], instance: "Pathpacking") -> bool: - """Check if a given path is part of the given instance.""" - edge_set = set(instance.edges) - edge_set |= {(v, u) for u, v in edge_set} - u, v, w = path - return (u, v) in edge_set and (v, w) in edge_set - - def score(self, instance: "Pathpacking") -> float: - return len(self.paths) +Pathpacking = Problem( + name="P_3 Path Packing", + min_size=3, + instance_cls=UndirectedGraph, + solution_cls=Solution, +) diff --git a/algobattle_problems/pathpacking/tests.py b/algobattle_problems/pathpacking/tests.py index 188df29..2ca206a 100644 --- a/algobattle_problems/pathpacking/tests.py +++ b/algobattle_problems/pathpacking/tests.py @@ -1,7 +1,7 @@ """Tests for the Pathpacking problem.""" import unittest -from algobattle_problems.pathpacking.problem import Pathpacking, ValidationError +from algobattle_problems.pathpacking.problem import UndirectedGraph, Solution, ValidationError, Role class Tests(unittest.TestCase): @@ -9,7 +9,7 @@ class Tests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: - cls.instance = Pathpacking( + cls.instance = UndirectedGraph( num_vertices=6, edges=[ (0, 1), @@ -21,19 +21,19 @@ def setUpClass(cls) -> None: ) def test_solution_empty(self): - Pathpacking.Solution(paths=set()).validate_solution(self.instance) + Solution(paths=set()).validate_solution(self.instance, Role.generator) def test_solution_not_path(self): with self.assertRaises(ValidationError): - Pathpacking.Solution(paths={(0, 2, 3)}).validate_solution(self.instance) + Solution(paths={(0, 2, 3)}).validate_solution(self.instance, Role.generator) def test_solution_not_disjoint(self): with self.assertRaises(ValidationError): - Pathpacking.Solution(paths={(0, 1, 2), (2, 3, 4)}).validate_solution(self.instance) + Solution(paths={(0, 1, 2), (2, 3, 4)}).validate_solution(self.instance, Role.generator) def test_score(self): - self.assertEqual(Pathpacking.Solution(paths={(0, 1, 2)}).score(self.instance), 1) - self.assertEqual(Pathpacking.Solution(paths={(0, 1, 2), (3, 4, 5)}).score(self.instance), 2) + self.assertEqual(Solution(paths={(0, 1, 2)}).score(self.instance, Role.solver), 1) + self.assertEqual(Solution(paths={(0, 1, 2), (3, 4, 5)}).score(self.instance, Role.solver), 2) if __name__ == "__main__": diff --git a/algobattle_problems/scheduling/problem.py b/algobattle_problems/scheduling/problem.py index 9d30c3b..5ca0daf 100644 --- a/algobattle_problems/scheduling/problem.py +++ b/algobattle_problems/scheduling/problem.py @@ -1,36 +1,40 @@ """The Scheduling problem class.""" -from typing import ClassVar - from pydantic import Field -from algobattle.problem import ProblemModel, SolutionModel, ValidationError +from algobattle.problem import Problem, InstanceModel, SolutionModel, ValidationError, minimize +from algobattle.util import Role -class Scheduling(ProblemModel): +class Instance(InstanceModel): """The Scheduling problem class.""" - name: ClassVar[str] = "Job Shop Scheduling" - min_size: ClassVar[int] = 5 - job_lengths: list[int] = Field(ge=0, le=(2**64 - 1) / 5) @property def size(self) -> int: return len(self.job_lengths) - class Solution(SolutionModel): - """A solution to a Job Shop Scheduling problem.""" - direction: ClassVar = "minimize" +class Solution(SolutionModel[Instance]): + """A solution to a Job Shop Scheduling problem.""" + + assignments: list[int] = Field(ge=1, le=5) + + def validate_solution(self, instance: Instance, role: Role) -> None: + if len(self.assignments) != len(instance.job_lengths): + raise ValidationError("The number of assigned jobs doesn't match the number of jobs.") - assignments: list[int] = Field(ge=1, le=5) + @minimize + def score(self, instance: Instance, role: Role) -> float: + finish_time = [0] * 5 + for duration, machine in zip(instance.job_lengths, self.assignments): + finish_time[machine - 1] += duration * machine + return max(finish_time) - def validate_solution(self, instance: "Scheduling") -> None: - if len(self.assignments) != len(instance.job_lengths): - raise ValidationError("The number of assigned jobs doesn't match the number of jobs.") - def score(self, instance: "Scheduling") -> float: - finish_time = [0] * 5 - for duration, machine in zip(instance.job_lengths, self.assignments): - finish_time[machine - 1] += duration * machine - return max(finish_time) +Scheduling = Problem( + name="Job Shop Scheduling", + min_size=5, + instance_cls=Instance, + solution_cls=Solution, +) diff --git a/algobattle_problems/scheduling/tests.py b/algobattle_problems/scheduling/tests.py index 66d5b86..0ed3fec 100644 --- a/algobattle_problems/scheduling/tests.py +++ b/algobattle_problems/scheduling/tests.py @@ -3,7 +3,7 @@ from pydantic import ValidationError as PydanticValidationError -from algobattle_problems.scheduling.problem import Scheduling, ValidationError +from algobattle_problems.scheduling.problem import Instance, Solution, ValidationError, Role class Tests(unittest.TestCase): @@ -11,19 +11,19 @@ class Tests(unittest.TestCase): @classmethod def setUpClass(cls): - cls.instance = Scheduling(job_lengths=[30, 120, 24, 40, 60]) + cls.instance = Instance(job_lengths=[30, 120, 24, 40, 60]) def test_solution_wrong_length(self): with self.assertRaises(ValidationError): - Scheduling.Solution(assignments=[]).validate_solution(self.instance) + Solution(assignments=[]).validate_solution(self.instance, Role.generator) def test_solution_wrong_machine(self): with self.assertRaises(PydanticValidationError): - Scheduling.Solution(assignments=[0, 0, 0, 0, 0]) + Solution(assignments=[0, 0, 0, 0, 0]) def test_solution_makespan(self): - solution = Scheduling.Solution(assignments=[4, 1, 5, 3, 2]) - self.assertEqual(solution.score(self.instance), 120) + solution = Solution(assignments=[4, 1, 5, 3, 2]) + self.assertAlmostEqual(solution.score(self.instance, Role.solver), 1 / 120) if __name__ == "__main__": diff --git a/algobattle_problems/tsptimewindows/problem.py b/algobattle_problems/tsptimewindows/problem.py index 14c666b..9570163 100644 --- a/algobattle_problems/tsptimewindows/problem.py +++ b/algobattle_problems/tsptimewindows/problem.py @@ -2,12 +2,11 @@ from itertools import pairwise from math import sqrt -from typing import ClassVar, Iterator, Self +from typing import Iterator, Self from pydantic import Field -from algobattle.problem import ProblemModel, SolutionModel, ValidationError -from algobattle.util import BaseModel, Role -from algobattle.util import u64 +from algobattle.problem import Problem, InstanceModel, SolutionModel, ValidationError, minimize +from algobattle.util import BaseModel, Role, u64 class Location(BaseModel): @@ -22,12 +21,9 @@ def distance(self, other: Self) -> float: return sqrt((self.x - other.x) ** 2 + (self.y - other.y) ** 2) -class Tsptimewindows(ProblemModel): +class Instance(InstanceModel): """The Tsptimewindows problem class.""" - name: ClassVar[str] = "Traveling Salesman with Time Windows" - min_size: ClassVar[int] = 5 - locations: list[Location] @property @@ -39,46 +35,42 @@ def validate_instance(self) -> None: if any(location.min_time > location.max_time for location in self.locations): raise ValidationError("An instance location has an invalid time window.") - class Solution(SolutionModel): - """A solution to a Traveling Salesman with Time Windows problem.""" - - tour: list[u64] - - def location_tour(self, instance: "Tsptimewindows") -> Iterator[Location]: - """Iterates over all locations in the tour in order, looping back around to the first.""" - yield from (instance.locations[i] for i in self.tour) - yield instance.locations[self.tour[0]] - - def validate_solution(self, instance: "Tsptimewindows") -> None: - if len(self.tour) != len(instance.locations): - raise ValidationError("The solution doesn't visit every location exactly once.") - if len(self.tour) != len(set(self.tour)): - raise ValidationError("The solution contains duplicate locations.") - if any(i >= len(instance.locations) for i in self.tour): - raise ValidationError("The solution contains invalid location indices.") - - # we don't know which team we are validating for, so we have to use the more lenient one some generator - # solutions that are incorrect won't be caught here, they will just receive a score of 0 - self.score(instance, Role.solver) - - def score(self, instance: "Tsptimewindows", team: Role) -> float: - speed = 1.1 if team == Role.solver else 1 # the solving team is faster than the generating - time = instance.locations[self.tour[0]].min_time # wait at the first location until it becomes available - for curr, next in pairwise(self.location_tour(instance)): - arrival_time = time + curr.distance(next) / speed - if arrival_time > next.max_time: - raise ValidationError("The tour visits a location too late.") - time = max(arrival_time, next.min_time) # wait until the next location becomes available - return time - - def score(self, solver_solution: Solution, generator_solution: Solution | None) -> float: - assert generator_solution is not None - try: - gen_score = generator_solution.score(self, Role.generator) - except ValidationError: - return 0 - sol_score = solver_solution.score(self, Role.solver) - if sol_score == 0: - return 0 - else: - return max(min(gen_score / sol_score, 1), 0) + +class Solution(SolutionModel[Instance]): + """A solution to a Traveling Salesman with Time Windows problem.""" + + tour: list[u64] + + def location_tour(self, instance: Instance) -> Iterator[Location]: + """Iterates over all locations in the tour in order, looping back around to the first.""" + yield from (instance.locations[i] for i in self.tour) + yield instance.locations[self.tour[0]] + + def validate_solution(self, instance: Instance, role: Role) -> None: + if len(self.tour) != len(instance.locations): + raise ValidationError("The solution doesn't visit every location exactly once.") + if len(self.tour) != len(set(self.tour)): + raise ValidationError("The solution contains duplicate locations.") + if any(i >= len(instance.locations) for i in self.tour): + raise ValidationError("The solution contains invalid location indices.") + + self.score(instance, role) + + @minimize + def score(self, instance: Instance, role: Role) -> float: + speed = 1.1 if role == Role.solver else 1 # the solving team is faster than the generating + time = instance.locations[self.tour[0]].min_time # wait at the first location until it becomes available + for curr, next in pairwise(self.location_tour(instance)): + arrival_time = time + curr.distance(next) / speed + if arrival_time > next.max_time: + raise ValidationError("The tour visits a location too late.") + time = max(arrival_time, next.min_time) # wait until the next location becomes available + return time + + +Tsptimewindows = Problem( + name="Traveling Salesman with Time Windows", + min_size=5, + instance_cls=Instance, + solution_cls=Solution, +) diff --git a/algobattle_problems/tsptimewindows/tests.py b/algobattle_problems/tsptimewindows/tests.py index 837aeab..a04b4ec 100644 --- a/algobattle_problems/tsptimewindows/tests.py +++ b/algobattle_problems/tsptimewindows/tests.py @@ -1,8 +1,14 @@ """Tests for the scheduling problem.""" import unittest -from algobattle_problems.tsptimewindows.problem import Tsptimewindows, ValidationError, Location -from algobattle.util import Role +from algobattle_problems.tsptimewindows.problem import ( + Tsptimewindows, + Instance, + Solution, + ValidationError, + Location, + Role, +) class Tests(unittest.TestCase): @@ -10,13 +16,13 @@ class Tests(unittest.TestCase): @classmethod def setUpClass(cls): - cls.instance = Tsptimewindows( + cls.instance = Instance( locations=[ Location(x=0, y=0, min_time=0, max_time=3), Location(x=1, y=0, min_time=1, max_time=2), ] ) - cls.instance_short = Tsptimewindows( + cls.instance_short = Instance( locations=[ Location(x=0, y=0, min_time=0, max_time=3), Location(x=1.05, y=0, min_time=0, max_time=1), @@ -30,35 +36,35 @@ def test_node_tour(self): self.instance.locations[1], self.instance.locations[0], ] - node_tour = list(Tsptimewindows.Solution(tour=tour).location_tour(self.instance)) + node_tour = list(Solution(tour=tour).location_tour(self.instance)) self.assertEqual(node_tour, nodes) def test_tour_too_short(self): with self.assertRaises(ValidationError): - Tsptimewindows.Solution(tour=[]).validate_solution(self.instance) + Solution(tour=[]).validate_solution(self.instance, Role.generator) def test_duplicate_in_tour(self): with self.assertRaises(ValidationError): - Tsptimewindows.Solution(tour=[0, 0]).validate_solution(self.instance) + Solution(tour=[0, 0]).validate_solution(self.instance, Role.generator) def test_tour_wrong_index(self): with self.assertRaises(ValidationError): - Tsptimewindows.Solution(tour=[10, 10]).validate_solution(self.instance) + Solution(tour=[10, 10]).validate_solution(self.instance, Role.generator) def test_tour_too_slow(self): with self.assertRaises(ValidationError): - Tsptimewindows.Solution(tour=[1, 0]).validate_solution(self.instance) + Solution(tour=[1, 0]).validate_solution(self.instance, Role.generator) def test_gen_tour_wrong(self): - solution = Tsptimewindows.Solution(tour=[0, 1]) - solution.validate_solution(self.instance_short) + solution = Solution(tour=[0, 1]) solution.score(self.instance_short, Role.solver) with self.assertRaises(ValidationError): - solution.score(self.instance_short, Role.generator) + solution.validate_solution(self.instance_short, Role.generator) def test_score_gen_wrong(self): - solution = Tsptimewindows.Solution(tour=[0, 1]) - self.assertEqual(self.instance_short.score(solution, solution), 0) + solution = Solution(tour=[0, 1]) + with self.assertRaises(ValidationError): + Tsptimewindows.score(self.instance_short, generator_solution=solution, solver_solution=solution) if __name__ == "__main__":