Skip to content

Commit 0c65306

Browse files
committed
feat: Rework variable and constraint format
Variables and constraints are now lists (technically sets) with a description of each type. This is more flexible than the previous approach and allows iterative refinement.
1 parent 5f3c385 commit 0c65306

13 files changed

Lines changed: 320 additions & 113 deletions

examples/demo.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from opltools import Suite, Problem, Library, Link, Variables, ValueRange
1+
from opltools import Suite, Problem, Library, Link, Variable, ValueRange
22
from pydantic_yaml import to_yaml_str
33
from opltools.schema import Implementation
44

@@ -10,24 +10,24 @@
1010
language="python",
1111
links=[
1212
Link(type="repository", url="https://github.com/numbbo/coco-experiment"),
13-
Link(type="package", url="https://pypi.org/project/coco-experiment/")
13+
Link(type="package", url="https://pypi.org/project/coco-experiment/"),
1414
],
15-
evaluation_time="sub second"
15+
evaluation_time="sub second",
1616
)
1717

1818
for fnr in range(1, 25):
1919
id = f"fn_bbob_f{fnr}"
2020
things[f"fn_bbob_f{fnr}"] = Problem(
2121
name=f"BBOB F_{fnr}",
2222
objectives={1},
23-
variables=Variables(continuous=ValueRange(min=1, max=80)),
24-
implementations=["impl_py_cocoex"]
23+
variables={Variable(type="continuous", dim=ValueRange(min=1, max=80))},
24+
implementations=["impl_py_cocoex"],
2525
)
2626

2727
things["suite_bbob"] = Suite(
2828
name="BBOB",
2929
problems={f"fn_bbob_f{fnr}" for fnr in range(1, 25)},
30-
implementations=["impl_py_cocoex"]
30+
implementations=["impl_py_cocoex"],
3131
)
3232

3333
library = Library(things)

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ dependencies = [
1616
[project.scripts]
1717
opl = "opltools.cli:main"
1818

19+
[project.optional-dependencies]
20+
cli = [
21+
"rich>=15.0.0",
22+
]
23+
1924
[build-system]
2025
requires = ["uv_build>=0.11.7,<0.12.0"]
2126
build-backend = "uv_build"

src/opltools/__init__.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
1-
from .schema import Problem, Suite, Generator, Implementation, Library, YesNoSome, Link, Reference, Variables, ValueRange, Constraints
1+
from .schema import (
2+
Problem,
3+
Suite,
4+
Generator,
5+
Implementation,
6+
Library,
7+
YesNoSome,
8+
Link,
9+
Reference,
10+
Variable,
11+
ValueRange,
12+
Constraint,
13+
)
214

315
__all__ = [
416
"Problem",
@@ -9,7 +21,7 @@
921
"YesNoSome",
1022
"Link",
1123
"Reference",
12-
"Constraints",
13-
"Variables",
14-
"ValueRange"
24+
"Constraint",
25+
"Variable",
26+
"ValueRange",
1527
]

src/opltools/cli.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ def cmd_validate(args):
2020
return 0
2121
except ValidationError as e:
2222
for error in e.errors():
23-
loc = " -> ".join(str(p) for p in error["loc"]) if error["loc"] else "(root)"
23+
loc = (
24+
" -> ".join(str(p) for p in error["loc"]) if error["loc"] else "(root)"
25+
)
2426
print(f"{args.file}: {loc}: {error['msg']}")
2527
return 1
2628

@@ -29,7 +31,9 @@ def main():
2931
parser = argparse.ArgumentParser(prog="opl", description="OPL tools")
3032
subparsers = parser.add_subparsers(dest="command", required=True)
3133

32-
validate_parser = subparsers.add_parser("validate", help="Validate a YAML file against the Library schema")
34+
validate_parser = subparsers.add_parser(
35+
"validate", help="Validate a YAML file against the Library schema"
36+
)
3337
validate_parser.add_argument("file", help="YAML file to validate")
3438

3539
args = parser.parse_args()

src/opltools/schema.py

Lines changed: 98 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,28 @@
22
from typing_extensions import Self
33
from pydantic import BaseModel, RootModel, ConfigDict, model_validator
44

5+
from .yesnosome import YesNoSome
56
from .utils import ValueRange, union_range
67

78

89
class OPLType(Enum):
910
problem = "problem"
1011
suite = "suite"
1112
generator = "generator"
12-
implementation = 'implementation'
13-
14-
15-
class YesNoSome(Enum):
16-
yes = "yes"
17-
no = "no"
18-
some = "some"
19-
unknown = "?"
13+
implementation = "implementation"
2014

2115

2216
class Link(BaseModel):
2317
type: str | None = None
2418
url: str
2519

20+
def __hash__(self):
21+
return hash(self.type) + hash(self.url)
22+
2623

2724
class Thing(BaseModel):
2825
type: OPLType
29-
model_config = ConfigDict(extra='allow')
26+
model_config = ConfigDict(extra="allow")
3027

3128

3229
class Objectives(RootModel):
@@ -37,45 +34,64 @@ def union(self, other: Self) -> Self:
3734
return self
3835

3936

40-
class Variables(BaseModel):
41-
continuous: int | set[int] | ValueRange = 0
42-
integer: int | set[int] | ValueRange = 0
43-
binary: int | set[int] | ValueRange = 0
44-
categorical: int | set[int] | ValueRange = 0
37+
class VariableType(Enum):
38+
continuous = "continuous"
39+
integer = "integer"
40+
binary = "binary"
41+
categorical = "categorical"
42+
unknown = "unknown"
4543

46-
def union(self, other: Self) -> Self:
47-
self.continuous = union_range(self.continuous, other.continuous)
48-
self.integer = union_range(self.integer, other.integer)
49-
self.binary = union_range(self.integer, other.binary)
50-
self.categorical = union_range(self.integer, other.categorical)
51-
return self
5244

45+
class Variable(BaseModel):
46+
type: VariableType = VariableType.unknown
47+
dim: int | set[int] | ValueRange | None = 0
5348

54-
class Constraints(BaseModel):
55-
box: int | set[int] | ValueRange = 0
56-
linear: int | set[int] | ValueRange = 0
57-
function: int | set[int] | ValueRange = 0
49+
def __hash__(self):
50+
if isinstance(self.dim, set):
51+
dim_hash = hash(frozenset(self.dim))
52+
else:
53+
dim_hash = hash(self.dim)
54+
return hash(self.type) + dim_hash
5855

59-
def union(self, other: Variables) -> Self:
60-
self.box = union_range(self.box, other.box)
61-
self.linear = union_range(self.linear, other.linear)
62-
self.function = union_range(self.function, other.function)
63-
return self
56+
57+
class ConstraintType(Enum):
58+
box = "box"
59+
linear = "linear"
60+
function = "function"
61+
unknown = "unknown"
62+
63+
64+
class Constraint(BaseModel):
65+
type: ConstraintType = ConstraintType.unknown
66+
hard: YesNoSome | None = None
67+
equality: YesNoSome | None = None
68+
number: int | set[int] | ValueRange | None = None
69+
70+
def __hash__(self):
71+
number = frozenset(self.number) if isinstance(self.number, set) else self.number
72+
return hash((self.type, self.hard, self.equality, number))
6473

6574

6675
class Reference(BaseModel):
6776
title: str
6877
authors: list[str]
6978
link: Link | None = None
7079

80+
def __hash__(self):
81+
return (
82+
hash(self.title)
83+
+ sum([hash(author) for author in self.authors])
84+
+ hash(self.link)
85+
)
86+
7187

7288
class Usage(BaseModel):
7389
language: str
7490
code: str
7591

7692

7793
class Implementation(Thing):
78-
type: OPLType= OPLType.implementation
94+
type: OPLType = OPLType.implementation
7995
name: str
8096
description: str
8197
links: list[Link] | None = None
@@ -92,9 +108,8 @@ class ProblemLike(Thing):
92108
references: set[Reference] | None = None
93109
implementations: set[str] | None = None
94110
objectives: set[int] | None = None
95-
variables: Variables | None = None
96-
constraints: Constraints | None = None
97-
soft_constraints: Constraints | None = None
111+
variables: set[Variable] | None = None
112+
constraints: set[Constraint] | None = None
98113
dynamic_type: set[str] | None = None
99114
noise_type: set[str] | None = None
100115
allows_partial_evaluation: YesNoSome | None = None
@@ -104,59 +119,90 @@ class ProblemLike(Thing):
104119
code_examples: set[str] | None = None
105120
source: set[str] | None = None
106121

122+
def __hash__(self):
123+
return hash((self.type, self.name))
124+
107125

108126
class Problem(ProblemLike):
109-
type:OPLType = OPLType.problem
127+
type: OPLType = OPLType.problem
110128
instances: ValueRange | list[str] | None = None
111129

112130

113131
class Suite(ProblemLike):
114-
type:OPLType = OPLType.suite
132+
type: OPLType = OPLType.suite
115133
problems: set[str] | None = None
116134

117135

118136
class Generator(ProblemLike):
119-
type:OPLType = OPLType.generator
137+
type: OPLType = OPLType.generator
120138

121139

122140
class Library(RootModel):
123-
root: dict[str, Problem | Generator | Suite | Implementation] | None
141+
root: dict[str, Problem | Generator | Suite | Implementation] = {}
124142

125-
def _check_id_references(self, ids, type:OPLType) -> None:
126-
if not self.root:
127-
return
143+
def _check_id_references(self, ids, type: OPLType) -> None:
128144
for id in ids:
129145
if id in self.root:
130146
if self.root[id].type != type:
131-
raise ValueError(f"ID {id} is a {self.root[id].name}, expected a {type.name}")
147+
raise ValueError(
148+
f"ID {id} is a {self.root[id].name}, expected a {type.name}"
149+
)
132150
else:
133151
raise ValueError(f"Missing {type.name} with id '{id}'")
134152

135-
def _fixup_fidelity(self, suite: Suite) -> Suite:
153+
# For a given suite, make sure the fidelty_levels property contains
154+
# the fidelity_levels of all problems in the suite.
155+
def _fixup_suite_fidelity(self, suite: Suite):
156+
if suite.problems:
157+
if not suite.fidelity_levels:
158+
suite.fidelity_levels = set()
159+
for pid in suite.problems:
160+
problem = self.root[pid]
161+
assert isinstance(problem, Problem)
162+
if problem.fidelity_levels:
163+
suite.fidelity_levels.update(problem.fidelity_levels)
164+
136165
return suite
137166

138-
@model_validator(mode="after")
139-
def validate(self) -> Self:
140-
if not self.root:
141-
return self
167+
def _fixup_suite_variables(self, suite: Suite):
168+
if not suite.problems:
169+
return
142170

171+
if suite.variables is None:
172+
suite.variables = set()
173+
for pid in suite.problems:
174+
problem = self.root[pid]
175+
assert isinstance(problem, Problem)
176+
if problem.variables is not None:
177+
suite.variables.update(problem.variables)
178+
179+
@model_validator(mode="after")
180+
def _validate(self) -> Self:
143181
# Make sure all problems referenced in suites exists
144182
for id, thing in self.root.items():
145183
if isinstance(thing, Suite) and thing.problems:
146184
for problem_id in thing.problems:
147185
if problem_id not in self.root:
148-
raise ValueError(f"Suite {id} references problem with undefined id '{problem_id}'.")
186+
raise ValueError(
187+
f"Suite {id} references problem with undefined id '{problem_id}'."
188+
)
149189
if self.root[problem_id].type != OPLType.problem:
150-
raise ValueError(f"Suite {id} references problem with id '{problem_id}' but id is a {self.root[problem_id].type.name}.")
190+
raise ValueError(
191+
f"Suite {id} references problem with id '{problem_id}' but id is a {self.root[problem_id].type.name}."
192+
)
193+
self._fixup_suite_fidelity(thing)
151194
return self
152195

196+
153197
__all__ = [
154-
"Problem",
155-
"Suite",
198+
"Constraint",
156199
"Generator",
157200
"Implementation",
158201
"Library",
159-
"YesNoSome",
160202
"Link",
161-
"Reference"
203+
"Problem",
204+
"Reference",
205+
"Suite",
206+
"Variable",
207+
"YesNoSome",
162208
]

src/opltools/utils.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@
44

55
class ValueRange(BaseModel):
66
min: int | None
7-
max: int | None
7+
max: int | None = None
88

99
@model_validator(mode="after")
1010
def _check(self) -> Self:
11-
if not self.min and not self.max:
11+
if self.min is None and self.max is None:
1212
raise ValueError("Variable range should have at least a min or max value.")
1313
return self
1414

15+
def __hash__(self):
16+
return hash((self.min, self.max))
17+
1518

1619
def _none_min(a, b):
1720
if a and b:
@@ -32,13 +35,12 @@ def _none_max(a, b):
3235

3336

3437
def union_range(
35-
a: int | set[int] | ValueRange,
36-
b: int | set[int] | ValueRange
38+
a: int | set[int] | ValueRange, b: int | set[int] | ValueRange
3739
) -> int | set[int] | ValueRange:
3840
if isinstance(a, int):
39-
a = { a }
41+
a = {a}
4042
if isinstance(b, int):
41-
b = { b }
43+
b = {b}
4244

4345
if isinstance(a, set) and isinstance(b, set):
4446
res = a.union(b)
@@ -55,7 +57,7 @@ def union_range(
5557
res.min = min(v, res.min)
5658
if res.max:
5759
for v in b:
58-
res.max = max(v, res.max)#
60+
res.max = max(v, res.max) #
5961
return res
6062

6163
raise Exception("BAM")

0 commit comments

Comments
 (0)