Skip to content

Commit 145a114

Browse files
authored
Merge pull request #147 from ImogenBits/logs
Improved match logs
2 parents 742e2fa + 39f108d commit 145a114

File tree

10 files changed

+219
-66
lines changed

10 files changed

+219
-66
lines changed

algobattle/battle.py

Lines changed: 103 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
some basic battle types, and related classed.
55
"""
66
from dataclasses import dataclass
7+
from enum import StrEnum
78
from functools import wraps
89
from importlib.metadata import entry_points
910
from abc import abstractmethod
@@ -30,6 +31,7 @@
3031
ConfigDict,
3132
Field,
3233
GetCoreSchemaHandler,
34+
SerializeAsAny,
3335
ValidationError,
3436
ValidationInfo,
3537
ValidatorFunctionWrapHandler,
@@ -39,12 +41,20 @@
3941

4042
from algobattle.program import (
4143
Generator,
42-
ProgramRunInfo,
44+
GeneratorResult,
45+
ProgramResult,
4346
ProgramUi,
47+
RunConfigOverride,
4448
Solver,
49+
SolverResult,
50+
)
51+
from algobattle.problem import InstanceModel, Problem, SolutionModel
52+
from algobattle.util import (
53+
Encodable,
54+
EncodableModel,
55+
ExceptionInfo,
56+
BaseModel,
4557
)
46-
from algobattle.problem import Problem
47-
from algobattle.util import Encodable, ExceptionInfo, BaseModel
4858

4959

5060
_BattleConfig: TypeAlias = Any
@@ -61,6 +71,54 @@
6171
Type = type
6272

6373

74+
class ProgramLogConfigTime(StrEnum):
75+
"""When to log a programs i/o."""
76+
77+
never = "never"
78+
error = "error"
79+
always = "always"
80+
81+
82+
class ProgramLogConfigLocation(StrEnum):
83+
"""Where to log a programs i/o."""
84+
85+
disabled = "disabled"
86+
inline = "inline"
87+
88+
89+
class ProgramLogConfigView(Protocol): # noqa: D101
90+
when: ProgramLogConfigTime = ProgramLogConfigTime.error
91+
output: ProgramLogConfigLocation = ProgramLogConfigLocation.inline
92+
93+
94+
class ProgramRunInfo(BaseModel):
95+
"""Data about a program's execution."""
96+
97+
runtime: float = 0
98+
overriden: RunConfigOverride = Field(default_factory=dict)
99+
error: ExceptionInfo | None = None
100+
battle_data: SerializeAsAny[EncodableModel] | None = None
101+
instance: SerializeAsAny[InstanceModel] | None = None
102+
solution: SerializeAsAny[SolutionModel[InstanceModel]] | None = None
103+
104+
@classmethod
105+
def from_result(cls, result: ProgramResult, *, inline_output: bool) -> Self:
106+
"""Converts the program run info into a jsonable model."""
107+
info = cls(
108+
runtime=result.runtime,
109+
overriden=result.overriden,
110+
error=result.error,
111+
)
112+
if inline_output:
113+
if isinstance(result.battle_data, EncodableModel):
114+
info.battle_data = result.battle_data
115+
if isinstance(result.solution, SolutionModel):
116+
info.solution = result.solution
117+
if isinstance(result, GeneratorResult) and isinstance(result.instance, InstanceModel):
118+
info.instance = result.instance
119+
return info
120+
121+
64122
class Fight(BaseModel):
65123
"""The result of one fight between the participating teams.
66124
@@ -79,6 +137,28 @@ class Fight(BaseModel):
79137
solver: ProgramRunInfo | None
80138
"""Data about the solver's execution."""
81139

140+
@classmethod
141+
def from_results(
142+
cls,
143+
max_size: int,
144+
score: float,
145+
generator: GeneratorResult,
146+
solver: SolverResult | None,
147+
*,
148+
config: ProgramLogConfigView,
149+
) -> Self:
150+
"""Turns the involved result objects into a jsonable model."""
151+
inline_output = config.when == "always" or (
152+
config.when == "error"
153+
and (generator.error is not None or (solver is not None and solver.error is not None))
154+
)
155+
return cls(
156+
max_size=max_size,
157+
score=score,
158+
generator=ProgramRunInfo.from_result(generator, inline_output=inline_output),
159+
solver=ProgramRunInfo.from_result(solver, inline_output=inline_output) if solver is not None else None,
160+
)
161+
82162

83163
class FightUi(ProgramUi, Protocol):
84164
"""Provides an interface for :class:`Fight` to update the ui."""
@@ -113,6 +193,7 @@ class FightHandler:
113193
battle: "Battle"
114194
ui: FightUi
115195
set_cpus: str | None
196+
log_config: ProgramLogConfigView
116197

117198
@_save_result
118199
async def run(
@@ -175,8 +256,14 @@ async def run(
175256
set_cpus=self.set_cpus,
176257
ui=ui,
177258
)
178-
if gen_result.info.error is not None:
179-
return Fight(score=1, max_size=max_size, generator=gen_result.info, solver=None)
259+
if gen_result.error is not None:
260+
return Fight.from_results(
261+
score=1,
262+
max_size=max_size,
263+
generator=gen_result,
264+
solver=None,
265+
config=self.log_config,
266+
)
180267
assert gen_result.instance is not None
181268

182269
sol_result = await self.solver.run(
@@ -190,8 +277,10 @@ async def run(
190277
set_cpus=self.set_cpus,
191278
ui=ui,
192279
)
193-
if sol_result.info.error is not None:
194-
return Fight(score=0, max_size=max_size, generator=gen_result.info, solver=sol_result.info)
280+
if sol_result.error is not None:
281+
return Fight.from_results(
282+
score=0, max_size=max_size, generator=gen_result, solver=sol_result, config=self.log_config
283+
)
195284
assert sol_result.solution is not None
196285

197286
if self.problem.with_solution:
@@ -202,7 +291,13 @@ async def run(
202291
else:
203292
score = self.problem.score(gen_result.instance, solution=sol_result.solution)
204293
score = max(0, min(1, float(score)))
205-
return Fight(score=score, max_size=max_size, generator=gen_result.info, solver=sol_result.info)
294+
return Fight.from_results(
295+
score=score,
296+
max_size=max_size,
297+
generator=gen_result,
298+
solver=sol_result,
299+
config=self.log_config,
300+
)
206301

207302

208303
# We need this to be here to prevent an import cycle between match.py and battle.py

algobattle/cli.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,9 +183,8 @@ def run_match(
183183
console.print(Padding(leaderboard, (1, 0, 0, 0)))
184184

185185
if save:
186-
res_string = result.model_dump_json(exclude_defaults=True)
187186
out_path = config.project.results.joinpath(f"match-{timestamp()}.json")
188-
out_path.write_text(res_string)
187+
out_path.write_text(result.format(error_detail=config.project.error_detail))
189188
console.print("Saved match result to ", out_path)
190189
return result
191190
except KeyboardInterrupt:

algobattle/match.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from itertools import combinations
66
from pathlib import Path
77
import tomllib
8-
from typing import Annotated, Any, Iterable, Protocol, ClassVar, Self, TypeAlias, TypeVar, cast
8+
from typing import Annotated, Any, Iterable, Literal, Protocol, ClassVar, Self, TypeAlias, TypeVar, cast
99
from typing_extensions import override
1010
from typing_extensions import TypedDict
1111

@@ -28,7 +28,15 @@
2828
from anyio.to_thread import current_default_thread_limiter
2929
from docker.types import LogConfig, Ulimit
3030

31-
from algobattle.battle import Battle, FightHandler, FightUi, BattleUi, Iterated
31+
from algobattle.battle import (
32+
Battle,
33+
FightHandler,
34+
FightUi,
35+
BattleUi,
36+
Iterated,
37+
ProgramLogConfigLocation,
38+
ProgramLogConfigTime,
39+
)
3240
from algobattle.program import ProgramConfigView, ProgramUi, Matchup, TeamHandler, BuildUi
3341
from algobattle.problem import Problem
3442
from algobattle.util import (
@@ -91,6 +99,7 @@ async def _run_battle(
9199
battle=battle,
92100
ui=battle_ui,
93101
set_cpus=set_cpus,
102+
log_config=config.project.log_program_io,
94103
)
95104
try:
96105
await battle.run_battle(
@@ -184,6 +193,30 @@ def calculate_points(self, total_points_per_team: int) -> dict[str, float]:
184193

185194
return points
186195

196+
def format(self, *, indent: int | None = 2, error_detail: Literal["high", "low"] = "low") -> str:
197+
"""Nicely formats the match result into a json string."""
198+
match error_detail:
199+
case "high":
200+
exclude = None
201+
case "low":
202+
detail = {"detail"}
203+
program = {"error": detail}
204+
exclude = {
205+
"excluded_teams": {"__all__": detail},
206+
"battles": {
207+
"__all__": {
208+
"runtime_error": detail,
209+
"fights": {
210+
"__all__": {
211+
"generator": program,
212+
"solver": program,
213+
}
214+
},
215+
}
216+
},
217+
}
218+
return self.model_dump_json(exclude_defaults=True, indent=indent, exclude=exclude)
219+
187220

188221
class Ui(BuildUi, Protocol):
189222
"""Base class for a UI that observes a Match and displays its data.
@@ -581,6 +614,13 @@ class DynamicProblemConfig(BaseModel):
581614
class ProjectConfig(BaseModel):
582615
"""Various project settings."""
583616

617+
class ProgramOutputConfig(BaseModel):
618+
"""How to log program output."""
619+
620+
# a bit janky atm, allows for future expansion
621+
when: ProgramLogConfigTime = ProgramLogConfigTime.error
622+
output: ProgramLogConfigLocation = ProgramLogConfigLocation.inline
623+
584624
parallel_battles: int = 1
585625
"""Number of battles exectuted in parallel."""
586626
name_images: bool = True
@@ -589,6 +629,12 @@ class ProjectConfig(BaseModel):
589629
"""Whether to clean up the images after we use them."""
590630
set_cpus: str | list[str] | None = None
591631
"""Wich cpus to run programs on, if it is a list each battle will use a different cpu specification for it."""
632+
error_detail: Literal["low", "high"] = "high"
633+
"""How detailed error messages should be.
634+
Higher settings help in debugging, but may leak information from other teams.
635+
"""
636+
log_program_io: ProgramOutputConfig = ProgramOutputConfig()
637+
"""How to log program output."""
592638
points: int = 100
593639
"""Highest number of points each team can achieve."""
594640
results: RelativePath = Field(default=Path("./results"), validate_default=True)

algobattle/program.py

Lines changed: 19 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
from docker.models.containers import Container as DockerContainer
2222
from docker.types import Mount
2323
from requests import Timeout, ConnectionError
24-
from pydantic import Field
2524
from anyio import run as run_async
2625
from anyio.to_thread import run_sync
2726
from urllib3.exceptions import ReadTimeoutError
@@ -37,7 +36,6 @@
3736
TempDir,
3837
ValidationError,
3938
Role,
40-
BaseModel,
4139
)
4240
from algobattle.problem import Problem, Instance, Solution
4341

@@ -162,35 +160,25 @@ def __exit__(self, exc: Any, val: Any, tb: Any):
162160
self._output.__exit__(exc, val, tb)
163161

164162

165-
class ProgramRunInfo(BaseModel):
166-
"""Data about a program's execution."""
163+
@dataclass(frozen=True)
164+
class SolverResult:
165+
"""The result of a solver execution."""
167166

168167
runtime: float = 0
169-
overriden: RunConfigOverride = Field(default_factory=dict)
168+
overriden: RunConfigOverride = field(default_factory=RunConfigOverride)
170169
error: ExceptionInfo | None = None
171-
172-
173-
@dataclass
174-
class ProgramResult:
175-
"""The result of a program execution."""
176-
177-
info: ProgramRunInfo
178170
battle_data: Encodable | None = None
171+
solution: Solution[Instance] | None = None
179172

180173

181-
@dataclass
182-
class GeneratorResult(ProgramResult):
183-
"""Result of a single generator execution."""
174+
@dataclass(frozen=True)
175+
class GeneratorResult(SolverResult):
176+
"""The result of a generator execution."""
184177

185178
instance: Instance | None = None
186-
solution: Solution[Instance] | None = None
187-
188179

189-
@dataclass
190-
class SolverResult(ProgramResult):
191-
"""Result of a single solver execution."""
192180

193-
solution: Solution[Instance] | None = None
181+
ProgramResult = GeneratorResult | SolverResult
194182

195183

196184
@dataclass
@@ -573,7 +561,9 @@ async def run(
573561
except Exception as e:
574562
exception_info = ExceptionInfo.from_exception(e)
575563
return GeneratorResult(
576-
info=ProgramRunInfo(runtime=runtime, overriden=specs.overriden, error=exception_info),
564+
runtime=runtime,
565+
overriden=specs.overriden,
566+
error=exception_info,
577567
battle_data=battle_data,
578568
instance=instance,
579569
solution=solution,
@@ -582,8 +572,8 @@ async def run(
582572
def test(self, max_size: int | None = None) -> Instance | ExceptionInfo:
583573
"""Tests whether the generator runs without issues and creates a syntactically valid instance."""
584574
res = run_async(self.run, max_size or self.problem.min_size)
585-
if res.info.error:
586-
return res.info.error
575+
if res.error:
576+
return res.error
587577
else:
588578
assert res.instance is not None
589579
return res.instance
@@ -658,16 +648,18 @@ async def run(
658648
except Exception as e:
659649
exception_info = ExceptionInfo.from_exception(e)
660650
return SolverResult(
661-
info=ProgramRunInfo(runtime=runtime, overriden=specs.overriden, error=exception_info),
651+
runtime=runtime,
652+
overriden=specs.overriden,
653+
error=exception_info,
662654
battle_data=battle_data,
663655
solution=solution,
664656
)
665657

666658
def test(self, instance: Instance) -> ExceptionInfo | None:
667659
"""Tests whether the solver runs without issues and creates a syntactically valid solution."""
668660
res = run_async(self.run, instance, instance.size)
669-
if res.info.error:
670-
return res.info.error
661+
if res.error:
662+
return res.error
671663
else:
672664
return None
673665

@@ -828,8 +820,6 @@ async def build(
828820
except Exception as e:
829821
handler.excluded[name] = ExceptionInfo.from_exception(e)
830822
ui.finish_build(name, False)
831-
except BaseException:
832-
raise
833823
else:
834824
ui.finish_build(name, True)
835825
return handler

0 commit comments

Comments
 (0)