Creating a Problem File¶
Algobattle uses Problem files to specify all the details of a problem formally, i.e. what instances and solutions should look like, how to score them, how to decode and encode them, etc. These are Python files and leave a lot of room for you to write them however you like. This overview will cover the basic structure of them and common use cases, for more advanced features refer to later sections of the problem creation guide.
Pairsum
Throughout this page we will use the Pairsum problem as our working example.
Initializing the Project Folder¶
When students develop their programs they are typically working within an Algobattle project folder created by the CLI
tool from a .algo
file. Our goal now is to create a brand-new project folder with problem files for our new problem.
Once we're done we can then package this into a .algo
file and distribute it to our students.
To initialize the folder we also use the algobattle init
command, but this time specify that we want to create a new
problem and its name.
algobattle init --new --problem "Pairsum"
This creates a new folder named Pairsum
with the following contents:
.
└─ Pairsum
├─ generator/
│ └─ Dockerfile
├─ results/
├─ solver/
│ └─ Dockerfile
├─ .gitignore
├─ problem.py
└─ algobattle.toml
Let us take a look at the contents of the problem.py
file, which is where we will implement the problem logic.
"""The Pairsum problem module."""
from algobattle.problem import Problem, InstanceModel, SolutionModel, maximize # (1)!
from algobattle.util import Role
class Instance(InstanceModel): # (2)!
"""Instances of Pairsum."""
...
@property
def size(self) -> int:
...
class Solution(SolutionModel[Instance]): # (3)!
"""Solutions of Pairsum."""
...
@maximize
def score(self, instance: Instance, role: Role) -> float:
...
Problem( # (4)!
name="Pairsum",
min_size=1,
instance_cls=Instance,
solution_cls=Solution,
)
- These lines import the needed parts of the Algobattle framework.
- This class specifies what instances of the problem look like.
- This class specifies what solutions of the problem look like.
- The
Problem
constructor ties all the information together and creates the problem.
As you can see, the file is mostly empty now and only contains some bare-bones structure. The ellipses tell us where we need to implement the basic problem specific code, though there also are more options for us to customize later on.
Type Checking
The Algobattle module is fully typed, but Python doesn't actually require you do include type hints in your own code. We strongly recommend still using type hints as much as possible since they prevent many bugs and make code easier to read. We will also make heavy use of type annotations to easily specify data formats.
The Instance Class¶
Let's start by looking at the Instance
class. This will specify what every instance of your problem should look like.
As you can see this class inherits from the InstanceModel
utility class, which uses
Pydantic to make instances that can easily be decoded to and from json files.
Advanced Usage
In the most general case they need to only inherit from the
Instance
class and implement the
Encodable
protocol, but doing so manually is much more
complicated and not needed for most problems. We will see how to use these classes in the
arbitrary i/o formats guide.
Pydantic
Pydantic is a very powerful library with excellent support for many use cases. This can also make it harder to understand how exactly everything works and what the "best" way of doing something is. For now, we recommend just staying here and focusing on how Algobattle lets us use it. If you're curious and want to see how it works under the hood you can then go back to its documentation later.
Instance Data¶
First we need to specify what an instance actually looks like. In our case it just is a list of natural numbers. This means we want the json file of an instance to look something like this:
{
"numbers": [1, 3, 17, 95, 0, 24, 6]
}
I.e. it contains a single field called numbers
which contains a list of non-negative integers. This can be copied to
the first ellipsis of the Instance class almost verbatim!
class Instance(InstanceModel):
"""Instances of Pairsum."""
numbers: list[int]
@property
def size(self) -> int:
...
Here we create a Python class attribute named after the key we want in the json file, and give it a type annotation that matches the kind of data we want at that key. If a json file contains a key that is not listed here, or if it is missing one of the keys listed here, the framework will assume that the given instance is malformed and reject it. Pydantic also ensures that the values at each key also match the types specified in the class!
Unfamiliar with Python type annotations?
Here's some more examples of type annotations and what they mean:
float
: a single real number.Literal["Ice Cream", "Cake", "Donuts"]
: one ofIce Cream
,Cake
, orDonuts
, no other strings or any other values are permitted.tuple[int, str]
: a tuple of an integer and a string. Since json only knows lists this will look like[123, "Cats"]
or[-17, "Dogs"]
, but never something like[1, 2]
or["Wombats", "are", "great"]
.dict[str, int]
: a mapping of strings to integers, e.g.{"Pasta": 1, "Pizza": 17, "Sushi": 5}
.set[list[str]]
: a set of lists of strings. Similar to tuples, json does not support sets so they will be encoded as plain lists, but with the requirement that no elements are duplicated and that order does not matter.
In general, you can use most basic types on their own, with collections having the type they contain in square brackets after them.
Example Instances
Here are some valid example instances:
{"numbers": [1, 2, 3]}
{"numbers": [17, 0, 0]}
{"numbers": [95, 74694, 65549, 6486681, 6513232135186, 651344168]}
These are invalid and will be rejected automatically:
{"numbers": [1, 2, 3], "other": 5}
{}
{"nums": [1, 2, 3]}
{"numbers"
{"numbers": [1.5, 2, 3]}
{"numbers": 17}
Additional Validation¶
But there still is an issue with this instance definition, it says that the numbers can be any integers and not just
natural numbers. This means that {"numbers": [-1, -2, -3]}
would also be accepted! Another potential issue is
that Python allows arbitrarily large numbers in its int
type, but many other languages make very large numbers hard to
work with. To make everything a bit fairer for different teams using different languages and not make winning a match
be based on exploiting corner case overflow bugs, we recommend also limiting the maximum size of the numbers, so they
can fit in a 64-bit integer. This can be done very easily by using the Algobattle utility types we provide in the
algobattle.types
module. In our case we want u64
for an unsigned 64-bit integer.
from algobattle.types import u64 # (1)!
class Instance(InstanceModel):
"""Instances of Pairsum."""
numbers: list[u64]
@property
def size(self) -> int:
...
- Always remember to add imports at the top of the file for everything you use from an external module.
Integer Types
The algobattle.types
module contains predefined types for all commonly found integer types. These are u64
,
u32
, u16
for unsigned integers that fit in 64, 32, and 16 bits and i64
, i32
, i16
for the corresponding
signed variants.
But there also are some properties that we cannot validate by just using one of the predefined types. The easiest way
to do that is to implement the validate_instance
method. It will be called after all the basic properties of the
types have been validated and can then perform more complicated checks. For example, if we wanted to also add the
constraint that all the numbers are even we could add this:
from algobattle.util import ValidationError
class Instance(InstanceModel):
"""Instances of Pairsum."""
numbers: list[u64]
def validate_instance(self) -> None: # (1)!
super().validate_instance() # (1)!
for number in self.numbers:
if number % 2 != 0:
raise ValidationError(
"A number in the instance is not even!",
detail=f"Odd number {number} was passed.",
)
@property
def size(self) -> int:
...
- The
validate_instance
method takes only the instance itself as an argument, and returns nothing. - Always include this line at the top of this method. It will call the parent's class validation logic to make sure that properties assured by it are actually enforced.
If the instance is valid this method should simply return nothing, if it is invalid it needs to raise a
ValidationError
.
Error Messages
You may notice the optional detail
argument of the ValidationError
exception. When the logs are visible for
everyone, accidentally leaking information about parts of an instance, may reveal the strategy of a team. On
the other hand, when developing code, a team may nevertheless want to see exactly what went wrong.
The first argument will always be visible to everyone, while the detail
field is hidden in match logs but visible
in local test runs. This means that the first argument should only contain a basic description of the general error
and detailed info should be in the detail
argument.
Implementing this method is entirely optional. Many simpler problems like Pairsum do not need it at all since their properties are easily encoded in just the predefined types.
Instance Size¶
Each instance also has a specific size that is used to limit the generating team so that it actually needs to produce
hard instances and not just big ones. In our case the size naturally just is the length of the list of numbers.
We specify what a specific instance's size is by implementing the size
property in the Instance class.
class Instance(InstanceModel):
"""Instances of Pairsum."""
numbers: list[u64]
@property
def size(self) -> int:
return len(self.numbers)
There are many more things you can customize here, but this is all you need to know to get started. If you want to take a deeper dive, check out the advanced problem creation pages once you've got a feeling for everything.
The Solution Class¶
The Solution class works very similar to the Instance class. Its job is to specify what solutions look like and how to score them. The data encoding and decoding can again be done using Pydantic:
class Solution(SolutionModel[Instance]):
"""Solutions of Pairsum."""
indices: tuple[u64, u64, u64, u64]
@maximize
def score(self, instance: Instance, role: Role) -> float:
...
But note that we're actually looking for four different indices into the list in the input, not just any four numbers. That means we need to validate that the numbers are valid indices (i.e. smaller than the length of the list) and are all different from each other. We could again do that with a custom validation method, but we can also use some more advanced utility types.
class Solution(SolutionModel[Instance]):
"""Solutions of Pairsum."""
indices: Annotated[tuple[SizeIndex, SizeIndex, SizeIndex, SizeIndex], UniqueItems]
@maximize
def score(self, instance: Instance, role: Role) -> float:
...
The first change is to use SizeIndex
instead of a u64
. This ensures that the numbers are valid indices into a list
of the length of the size
of the instance. In our case the size is defined to be exactly the length of the list we
want to index, so this works perfectly. The other change is that we add a Annotated[..., UniqueItems]
wrapped around
the actual type. This is a Python construct that lets us add some metadata to a type annotation. The UniqueItems
data
will instruct Algobattle (again using Pydantic) to also validate that the items in the wrapped collection are different
from each other.
Annotated Metadata
Using the Annotated[...]
construct to add metadata is a powerful way to define validation, but can also be very
confusing to people new to the Python type system. We go over it in much more detail in the
advanced types section.
We also need to check that the first two numbers actually have the same sum as the second two. This is best done with a custom validation method:
class Solution(SolutionModel[Instance]):
"""Solutions of Pairsum."""
indices: Annotated[tuple[SizeIndex, SizeIndex, SizeIndex, SizeIndex], UniqueItems]
def validate_solution(self, instance: Instance, role: Role) -> None:
super().validate_solution(instance, role)
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.")
@maximize
def score(self, instance: Instance, role: Role) -> float:
...
Note that this is now called validate_solution
and takes not only the solution itself, but also the instance it is
trying to solve and the role of the team that created this solution as arguments.
Role Argument
Most of the time the role argument won't be used to validate a solution. You must still have it listed in the argument list of the method for everything to work smoothly. Some problems use this to e.g. relax some condition for the solving team.
Solution Score¶
Many problems not only care about a team providing a valid solution but also want them to compute the best solution they
can. For example, we might modify to not just want any four such numbers, but want the sum of each pair to be as big as
possible. For these problems we implement the score
function. If we leave it out all solutions will be scored
equally.
class Solution(SolutionModel[Instance]):
"""Solutions of Pairsum."""
indices: Annotated[tuple[SizeIndex, SizeIndex, SizeIndex, SizeIndex], UniqueItems]
def validate_solution(self, instance: Instance, role: Role) -> None:
super().validate_solution(instance, role)
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.")
@maximize
def score(self, instance: Instance, role: Role) -> float:
return instance.numbers[self.indices[0]] + instance.numbers[self.indices[1]]
This method again receives the solution itself, the instance it solves, and the role of the team that generated it. It
needs to return a non-negative real number indicating how good the solution is. When using the @maximize
decorator
(or using no decorator at all) bigger scores are considered better, if the problem instead asks for the smallest of
some value instead import and use the @minimize
decorator.
Constructing the Problem¶
Now that we have an Instance and a Solution class we can tie everything together using the Problem constructor.
Problem(
name="Pairsum",
min_size=4,
instance_cls=Instance,
solution_cls=Solution,
)
In its most basic form it just takes the name of the problem, and both classes we defined above. Finally, it also takes
a number that defines what the smallest reasonable instance size for this problem can be. This is needed because in our
case there aren't any sensible problem instances that only contain 3 numbers since we're looking for two pairs of two
numbers in the list. So if a generator was asked to create an instance of size 3 they couldn't possibly do this and
would fail immediately. To prevent bugs like that fill in min_size
with whatever the smallest size your problem can
properly operate at is.
In summary, our final Pairsum problem file looks like this
from typing import Annotated
from algobattle.problem import Problem, InstanceModel, SolutionModel
from algobattle.util import Role, ValidationError
from algobattle.types import u64, MinLen, SizeIndex, UniqueItems
class Instance(InstanceModel):
"""An instance of a Pairsum problem."""
numbers: Annotated[list[u64], MinLen(4)]
@property
def size(self) -> int:
return len(self.numbers)
class Solution(SolutionModel[Instance]):
"""A solution to a Pairsum problem."""
indices: Annotated[tuple[SizeIndex, SizeIndex, SizeIndex, SizeIndex], UniqueItems]
def validate_solution(self, instance: Instance, role: Role) -> None:
super().validate_solution(instance, role)
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.")
Problem(
name="Pairsum",
min_size=4,
instance_cls=Instance,
solution_cls=Solution,
)
Creating a Description¶
Now that we've made the problem file to tell the framework how our problem works, we need to create a description file
to tell our students the same. This can be any file that just describes the problem in human-readable terms. It will be
packaged together with the problem file and distributed to the students. By default, Algobattle expects this file to be
named description
with an appropriate file ending, e.g. description.md
, description.pdf
, etc.
Web Framework
When using the Algobattle web framework, Markdown files work best for this since they can be displayed inline on the problem page.
# The Pairsum Problem
The Pairsum problem asks you to find two pairs of numbers in a list that have the same sum. I.e.:
**Given**: List `L = [z_1,...,z_n]`
**Question**: Are there pairwise different `a, b, c, d in [0,...,n-1]` such that `L[a] + L[b] = L[c] + L[d]`?
I.e. given a list of natural numbers the task is to find two pairs of these numbers with the same sum.
The `size` of an instance limits the length of the list of numbers.
The generator should create a hard to solve instance and a certificate solution to prove that such a pair of pairs
indeed exists. The generator should be able to efficiently find the solution for any input list.
## Instances
An instance just contains the list of numbers. For example:
```json
{
"numbers": [1, 2, 3, 4, 5]
}
```
## Solutions
A solution contains a list with the four indices `a, b, c, d` in this order. For example:
```json
{
"indices": [1, 4, 2, 3]
}
```
This is a valid solution since `L[1] + L[4] = 2 + 5 = 3 + 4 = L[2] + L[3]`.
Packaging¶
To easily distribute your problem to your students you can use the Algobattle CLI like this:
algobattle package problem
This creates a pairsum.algo
file which contains all the info Algobattle needs to initialize a new project folder on
the student's computer with your problem. Note that it will then not only contain the problem file, but also the
description, and the Algobattle config file.
A peek behind the curtain
This file really just is a zip file containing the mentioned files that have been preprocessed slightly. The file extension is there to indicate that you shouldn't pack or unpack these files manually since the CLI tool expects them to be formatted in a precise way.
Web Framework
This file is what the web framework expects you to upload, it will then be used to run matches on the server and be distributed to the students.