Skip to content

Commit abf4c14

Browse files
authored
Merge pull request #73 from ImogenBits/problem
rework problem and battle wrapper classes
2 parents 7cc3a67 + cac7170 commit abf4c14

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

61 files changed

+1343
-1317
lines changed

.github/workflows/python-app.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
- name: Set up Python
1818
uses: actions/setup-python@v2
1919
with:
20-
python-version: "3.10"
20+
python-version: "3.11"
2121
- name: Install dependencies
2222
run: |
2323
python -m pip install --upgrade pip
@@ -36,7 +36,7 @@ jobs:
3636
- name: Set up Python
3737
uses: actions/setup-python@v2
3838
with:
39-
python-version: "3.10"
39+
python-version: "3.11"
4040
- name: install Docker
4141
if: runner.os == 'macOS'
4242
uses: docker-practice/actions-setup-docker@master

algobattle/battle.py

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@
1515
# for now we need to manually import the default wrappers to make sure they're initialized
1616
from algobattle.battle_wrappers.iterated import Iterated # type: ignore # noqa: F401
1717
from algobattle.battle_wrappers.averaged import Averaged # type: ignore # noqa: F401
18+
from algobattle.docker_util import DockerConfig, RunParameters
1819

19-
from algobattle.match import MatchConfig, run_match
20+
from algobattle.match import MatchConfig, Match
21+
from algobattle.problem import Problem
2022
from algobattle.team import TeamHandler, TeamInfo
2123
from algobattle.ui import Ui
22-
from algobattle.util import check_path, getattr_set, import_problem_from_path
24+
from algobattle.util import check_path, getattr_set
2325

2426

2527
def setup_logging(logging_path: Path, verbose_logging: bool, silent: bool):
@@ -79,7 +81,7 @@ class ProgramConfig:
7981
logs: Path = Path.home() / ".algobattle_logs"
8082

8183

82-
def parse_cli_args(args: list[str]) -> tuple[ProgramConfig, MatchConfig, BattleWrapper.Config]:
84+
def parse_cli_args(args: list[str]) -> tuple[ProgramConfig, DockerConfig, MatchConfig, BattleWrapper.Config]:
8385
"""Parse a given CLI arg list into config objects."""
8486
parser = ArgumentParser()
8587
parser.add_argument("problem", type=check_path, help="Path to a folder with the problem file.")
@@ -99,7 +101,8 @@ def parse_cli_args(args: list[str]) -> tuple[ProgramConfig, MatchConfig, BattleW
99101
parser.add_argument("--timeout_solver", type=float, help="Time limit for the solver execution.")
100102
parser.add_argument("--space_generator", type=int, help="Memory limit for the generator execution, in MB.")
101103
parser.add_argument("--space_solver", type=int, help="Memory limit the solver execution, in MB.")
102-
parser.add_argument("--cpus", type=int, help="Number of cpu cores used for each docker container execution.")
104+
parser.add_argument("--cpus_generator", type=int, help="Number of cpu cores used for generator container execution.")
105+
parser.add_argument("--cpus_solver", type=int, help="Number of cpu cores used for solver container execution.")
103106

104107
# battle wrappers have their configs automatically added to the CLI args
105108
for wrapper_name, wrapper in BattleWrapper.all().items():
@@ -110,7 +113,7 @@ def parse_cli_args(args: list[str]) -> tuple[ProgramConfig, MatchConfig, BattleW
110113
parsed = parser.parse_args(args)
111114

112115
if parsed.battle_type is not None:
113-
parsed.battle_type = BattleWrapper.get_wrapper(parsed.battle_type)
116+
parsed.battle_type = BattleWrapper.all()[parsed.battle_type]
114117
cfg_path = parsed.config if parsed.config is not None else parsed.problem / "config.toml"
115118
if cfg_path.is_file():
116119
with open(cfg_path, "rb") as file:
@@ -125,7 +128,7 @@ def parse_cli_args(args: list[str]) -> tuple[ProgramConfig, MatchConfig, BattleW
125128
team_specs = config["teams"]
126129
else:
127130
team_specs = [{
128-
"name": "team 0",
131+
"name": "team_0",
129132
"generator": parsed.problem / "generator",
130133
"solver": parsed.problem / "solver",
131134
}]
@@ -141,42 +144,57 @@ def parse_cli_args(args: list[str]) -> tuple[ProgramConfig, MatchConfig, BattleW
141144

142145
program_config = ProgramConfig(teams=teams, **getattr_set(parsed, "problem", "display", "logs"))
143146

144-
match_config = MatchConfig.from_dict(config.get("algobattle", {}))
147+
match_config = MatchConfig.from_dict(config.get("match", {}))
145148
for name in vars(match_config):
146149
if getattr(parsed, name) is not None:
147150
setattr(match_config, name, getattr(parsed, name))
148151

152+
docker_params = config.get("docker", {})
153+
docker_config = DockerConfig(
154+
build_timeout=docker_params.get("build_timeout"),
155+
generator=RunParameters(**docker_params.get("generator", {})),
156+
solver=RunParameters(**docker_params.get("solver", {})),
157+
)
158+
if getattr(parsed, "timeout_build") is not None:
159+
object.__setattr__(docker_config, "build_timeout", parsed.timeout_build)
160+
for role in ("generator", "solver"):
161+
role_config = getattr(docker_config, role)
162+
for name in vars(role_config):
163+
cli_name = f"{name}_{role}"
164+
if getattr(parsed, cli_name) is not None:
165+
object.__setattr__(role_config, name, getattr(parsed, cli_name))
166+
149167
wrapper_config = match_config.battle_type.Config(**config.get(match_config.battle_type.name().lower(), {}))
150168
for name in vars(wrapper_config):
151169
cli_name = f"{match_config.battle_type.name().lower()}_{name}"
152170
if getattr(parsed, cli_name) is not None:
153171
setattr(wrapper_config, name, getattr(parsed, cli_name))
154172

155-
return program_config, match_config, wrapper_config
173+
return program_config, docker_config, match_config, wrapper_config
156174

157175

158176
def main():
159177
"""Entrypoint of `algobattle` CLI."""
160178
try:
161-
program_config, match_config, wrapper_config = parse_cli_args(sys.argv[1:])
179+
program_config, docker_config, match_config, wrapper_config = parse_cli_args(sys.argv[1:])
162180
logger = setup_logging(program_config.logs, match_config.verbose, program_config.display != "logs")
163181

164182
except KeyboardInterrupt:
165183
raise SystemExit("Received keyboard interrupt, terminating execution.")
166184

167185
try:
168-
problem = import_problem_from_path(program_config.problem)
169-
with TeamHandler.build(program_config.teams) as teams, ExitStack() as stack:
186+
problem = Problem.import_from_path(program_config.problem)
187+
with TeamHandler.build(program_config.teams, problem, docker_config) as teams, ExitStack() as stack:
170188
if program_config.display == "ui":
171189
ui = Ui()
172190
stack.enter_context(ui)
173191
else:
174192
ui = None
175193

176-
result = run_match(match_config, wrapper_config, problem, teams, ui)
194+
result = Match.run(match_config, wrapper_config, problem, teams, ui)
177195

178196
logger.info('#' * 78)
179-
logger.info(str(result))
197+
logger.info(result.display())
180198
if match_config.points > 0:
181199
points = result.calculate_points(match_config.points)
182200
for team, pts in points.items():

algobattle/battle_wrapper.py

Lines changed: 137 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -5,114 +5,172 @@
55
characteristic that they are responsible for updating some match data during
66
their run, such that it contains the current state of the match.
77
"""
8-
from __future__ import annotations
9-
from dataclasses import dataclass
8+
from dataclasses import dataclass, field as dataclass_field, fields
109
from importlib.metadata import entry_points
1110
import logging
1211
from abc import abstractmethod, ABC
13-
from importlib import import_module
14-
from typing import Type
12+
from typing import (
13+
Any,
14+
Callable,
15+
ClassVar,
16+
Literal,
17+
Mapping,
18+
TypeAlias,
19+
TypeVar,
20+
dataclass_transform,
21+
get_origin,
22+
get_type_hints,
23+
)
24+
from algobattle.docker_util import DockerError, Generator, Solver, GeneratorResult, SolverResult
25+
from algobattle.observer import Subject
26+
from algobattle.util import Encodable, Role
1527

16-
from algobattle.fight_handler import FightHandler
17-
from algobattle.team import Matchup
18-
from algobattle.observer import Observer, Subject
19-
from algobattle.util import CLIParsable
28+
logger = logging.getLogger("algobattle.battle_wrapper")
2029

21-
logger = logging.getLogger('algobattle.battle_wrapper')
2230

31+
_Config: TypeAlias = Any
32+
T = TypeVar("T")
2333

24-
class BattleWrapper(ABC):
34+
35+
def argspec(*, default: T, help: str = "", parser: Callable[[str], T] | None = None) -> T:
36+
"""Structure specifying the CLI arg."""
37+
metadata = {"help": help, "parser": parser}
38+
return dataclass_field(default=default, metadata={key: val for key, val in metadata.items() if val is not None})
39+
40+
41+
@dataclass
42+
class CombinedResults:
43+
"""The result of one execution of the generator and the solver with the generated instance."""
44+
45+
score: float
46+
generator: GeneratorResult | DockerError
47+
solver: SolverResult | DockerError | None
48+
49+
50+
class BattleWrapper(Subject, ABC):
2551
"""Abstract Base class for wrappers that execute a specific kind of battle."""
2652

27-
@dataclass
28-
class Config(CLIParsable):
29-
"""Object containing the config variables the wrapper will use."""
53+
_wrappers: ClassVar[dict[str, type["BattleWrapper"]]] = {}
54+
55+
scoring_team: ClassVar[Role] = "solver"
3056

31-
pass
57+
@dataclass_transform(field_specifiers=(argspec,))
58+
class Config:
59+
"""Object containing the config variables the wrapper will use."""
3260

33-
_wrappers: dict[str, Type[BattleWrapper]] = {}
61+
def __init_subclass__(cls) -> None:
62+
dataclass(cls)
63+
super().__init_subclass__()
64+
65+
# providing a dummy default impl that will be overriden, to get better static analysis
66+
def __init__(self, **kwargs) -> None:
67+
super().__init__()
68+
69+
@classmethod
70+
def as_argparse_args(cls) -> list[tuple[str, dict[str, Any]]]:
71+
"""Constructs a list of argument names and `**kwargs` that can be passed to `ArgumentParser.add_argument()`."""
72+
arguments: list[tuple[str, dict[str, Any]]] = []
73+
resolved_annotations = get_type_hints(cls)
74+
for field in fields(cls):
75+
kwargs = {
76+
"type": field.metadata.get("parser", resolved_annotations[field.name]),
77+
"help": field.metadata.get("help", "") + f" Default: {field.default}",
78+
}
79+
if field.type == bool:
80+
kwargs["action"] = "store_const"
81+
kwargs["const"] = not field.default
82+
elif get_origin(field.type) == Literal:
83+
kwargs["choices"] = field.type.__args__
84+
85+
arguments.append((field.name, kwargs))
86+
return arguments
3487

3588
@staticmethod
36-
def all() -> dict[str, Type[BattleWrapper]]:
89+
def all() -> dict[str, type["BattleWrapper"]]:
3790
"""Returns a list of all registered wrappers."""
3891
for entrypoint in entry_points(group="algobattle.wrappers"):
3992
if entrypoint.name not in BattleWrapper._wrappers:
40-
wrapper: Type[BattleWrapper] = entrypoint.load()
41-
BattleWrapper._wrappers[wrapper.name()] = wrapper
93+
wrapper: type[BattleWrapper] = entrypoint.load()
94+
BattleWrapper._wrappers[wrapper.name().lower()] = wrapper
4295
return BattleWrapper._wrappers
4396

44-
def __init_subclass__(cls) -> None:
97+
def __init_subclass__(cls, notify_var_changes: bool = False) -> None:
4598
if cls.name() not in BattleWrapper._wrappers:
46-
BattleWrapper._wrappers[cls.name()] = cls
47-
return super().__init_subclass__()
99+
BattleWrapper._wrappers[cls.name().lower()] = cls
100+
return super().__init_subclass__(notify_var_changes)
48101

49-
@staticmethod
50-
def get_wrapper(wrapper_name: str) -> Type[BattleWrapper]:
51-
"""Try to import a Battle Wrapper from a given name.
52-
53-
For this to work, a BattleWrapper module with the same name as the argument
54-
needs to be present in the algobattle/battle_wrappers folder.
55-
56-
Parameters
57-
----------
58-
wrapper_name : str
59-
Name of a battle wrapper module in algobattle/battle_wrappers.
60-
61-
Returns
62-
-------
63-
BattleWrapper
64-
A BattleWrapper of the given wrapper_name.
65-
66-
Raises
67-
------
68-
ValueError
69-
If the wrapper does not exist in the battle_wrappers folder.
70-
"""
71-
try:
72-
wrapper_module = import_module("algobattle.battle_wrappers." + wrapper_name)
73-
return getattr(wrapper_module, wrapper_name.capitalize())
74-
except ImportError as e:
75-
logger.critical(f"Importing a wrapper from the given path failed with the following exception: {e}")
76-
raise ValueError from e
102+
@abstractmethod
103+
def score(self) -> float:
104+
"""The score achieved by the scored team during this battle."""
105+
raise NotImplementedError
77106

78-
def __init__(self, fight_handler: FightHandler, config: BattleWrapper.Config) -> None:
79-
super().__init__()
80-
self.fight_handler = fight_handler
81-
self.config = config
107+
@staticmethod
108+
def format_score(score: float) -> str:
109+
"""Formats a score nicely."""
110+
return f"{score:.2f}"
82111

83112
@abstractmethod
84-
def run_round(self, matchup: Matchup, observer: Observer | None = None) -> BattleWrapper.Result:
85-
"""Execute a full round of fights between two teams configured in the fight_handler.
86-
87-
During execution, the concrete BattleWrapper should update the round_data dict
88-
to which Observers can subscribe in order to react to new intermediate results.
89-
"""
113+
def display(self) -> str:
114+
"""Nicely formats the object."""
90115
raise NotImplementedError
91116

92117
@classmethod
93118
def name(cls) -> str:
94119
"""Name of the type of this battle wrapper."""
95120
return cls.__name__
96121

97-
class Result(Subject):
98-
"""Result of a single battle."""
99-
100-
@property
101-
@abstractmethod
102-
def score(self) -> float:
103-
"""The score achieved by the solver of this battle."""
104-
raise NotImplementedError
105-
106-
@staticmethod
107-
@abstractmethod
108-
def format_score(score: float) -> str:
109-
"""Formats a score nicely."""
110-
raise NotImplementedError
122+
@abstractmethod
123+
def run_battle(self, generator: Generator, solver: Solver, config: _Config, min_size: int) -> None:
124+
"""Calculates the next instance size that should be fought over."""
125+
raise NotImplementedError
111126

112-
def __str__(self) -> str:
113-
return self.format_score(self.score)
127+
def run_programs(
128+
self,
129+
generator: Generator,
130+
solver: Solver,
131+
size: int,
132+
*,
133+
timeout_generator: float | None = ...,
134+
space_generator: int | None = ...,
135+
cpus_generator: int = ...,
136+
timeout_solver: float | None = ...,
137+
space_solver: int | None = ...,
138+
cpus_solver: int = ...,
139+
generator_battle_input: Mapping[str, Encodable] = {},
140+
solver_battle_input: Mapping[str, Encodable] = {},
141+
generator_battle_output: Mapping[str, type[Encodable]] = {},
142+
solver_battle_output: Mapping[str, type[Encodable]] = {},
143+
) -> CombinedResults:
144+
"""Execute a single fight of a battle, running the generator and solver and handling any errors gracefully."""
145+
self.notify()
146+
try:
147+
gen_result = generator.run(
148+
size=size,
149+
timeout=timeout_generator,
150+
space=space_generator,
151+
cpus=cpus_generator,
152+
battle_input=generator_battle_input,
153+
battle_output=generator_battle_output,
154+
)
155+
except DockerError as e:
156+
return CombinedResults(score=1, generator=e, solver=None)
114157

115-
@abstractmethod
116-
def display(self) -> str:
117-
"""Nicely formats the object."""
118-
raise NotImplementedError
158+
try:
159+
sol_result = solver.run(
160+
gen_result.problem,
161+
size=size,
162+
timeout=timeout_solver,
163+
space=space_solver,
164+
cpus=cpus_solver,
165+
battle_input=solver_battle_input,
166+
battle_output=solver_battle_output,
167+
)
168+
except DockerError as e:
169+
return CombinedResults(score=0, generator=gen_result, solver=e)
170+
171+
score = gen_result.problem.calculate_score(
172+
solution=sol_result.solution, generator_solution=gen_result.solution, size=size
173+
)
174+
score = max(0, min(1, float(score)))
175+
logger.info(f"The solver achieved a score of {score}.")
176+
return CombinedResults(score, gen_result, sol_result)

0 commit comments

Comments
 (0)