rubberruler 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ """Decide with BBOB, MA-BBOB, or your own objective functions whether to compute
2
+ features value-based or rank-based.
3
+
4
+ Stretch the ruler. Keep the features that still measure.
5
+ """
6
+
7
+ from rubberruler._errors import RubberRulerError
8
+ from rubberruler._features import compute_features
9
+ from rubberruler._instances import bbob_instances, ma_bbob_instances, transform
10
+ from rubberruler._objective import objective_instances, transform_objective
11
+ from rubberruler._performance import measure_performance
12
+ from rubberruler._prediction import fit_predictor, r2_score
13
+ from rubberruler._problem import Problem
14
+ from rubberruler._run_test import run_test
15
+
16
+ __all__ = [
17
+ "Problem",
18
+ "RubberRulerError",
19
+ "bbob_instances",
20
+ "compute_features",
21
+ "fit_predictor",
22
+ "ma_bbob_instances",
23
+ "measure_performance",
24
+ "objective_instances",
25
+ "r2_score",
26
+ "run_test",
27
+ "transform",
28
+ "transform_objective",
29
+ ]
rubberruler/_cache.py ADDED
@@ -0,0 +1,535 @@
1
+ """The run cache in cache_dir, and how algorithms are told apart.
2
+
3
+ Each finished task is one Parquet file in ``cache_dir/runs``. Only the parent
4
+ process writes, under a temporary name that ``os.replace`` then makes visible, so no
5
+ lock is needed. An algorithm is identified by its name, its source, its default and
6
+ closure values, and the global values it reads.
7
+ """
8
+
9
+ import functools
10
+ import hashlib
11
+ import importlib.metadata
12
+ import inspect
13
+ import json
14
+ import os
15
+ import platform
16
+ import site
17
+ import sys
18
+ import sysconfig
19
+ import tokenize
20
+ import types
21
+ import uuid
22
+ from collections.abc import Mapping, Sequence
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+
26
+ import numpy as np
27
+ import pyarrow as pa
28
+ import pyarrow.dataset as ds
29
+ import pyarrow.parquet as pq
30
+
31
+ from rubberruler._errors import error
32
+
33
+ CACHE_FORMAT = 1
34
+ RUNS_FOLDER = "runs"
35
+ MAX_DEPTH = 50
36
+ SCHEMA = pa.schema(
37
+ [
38
+ ("instance_id", pa.string()),
39
+ ("seed", pa.int64()),
40
+ ("algorithm", pa.string()),
41
+ ("identifier", pa.string()),
42
+ ("budget", pa.int64()),
43
+ ("run_number", pa.int64()),
44
+ ("ioh_version", pa.string()),
45
+ ("cache_format", pa.int64()),
46
+ ("best", pa.float64()),
47
+ ("evaluations", pa.int64()),
48
+ ("failed", pa.bool_()),
49
+ ("message", pa.string()),
50
+ ]
51
+ )
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class RunResult:
56
+ """One finished run: the smallest value within the budget and how it ended."""
57
+
58
+ instance_id: str
59
+ seed: int
60
+ algorithm: str
61
+ budget: int
62
+ run_number: int
63
+ best: float
64
+ evaluations: int
65
+ failed: bool
66
+ message: str
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Identity:
71
+ """The identifier of an algorithm, and the warning if it is not fully identified."""
72
+
73
+ identifier: str
74
+ warning: str | None
75
+
76
+
77
+ class RunStore:
78
+ """Finished runs in ``directory/runs``, one Parquet file per finished task."""
79
+
80
+ def __init__(self, directory: Path) -> None:
81
+ self.folder = directory / RUNS_FOLDER
82
+
83
+ def load(
84
+ self, identifiers: Mapping[str, str]
85
+ ) -> dict[tuple[str, int, str, int, int], RunResult]:
86
+ """The cached runs of these algorithms.
87
+
88
+ The keys are (instance_id, seed, algorithm, budget, run_number). Runs of
89
+ another identifier, ioh version, or cache format are not used. A cache that
90
+ cannot be read counts as empty, so its runs are computed again.
91
+ """
92
+ if not self.folder.is_dir():
93
+ return {}
94
+ try:
95
+ dataset = ds.dataset(
96
+ self.folder, format="parquet", schema=SCHEMA, exclude_invalid_files=True
97
+ )
98
+ table = dataset.to_table(
99
+ filter=(ds.field("cache_format") == CACHE_FORMAT)
100
+ & (ds.field("ioh_version") == ioh_version())
101
+ & ds.field("identifier").isin(sorted(set(identifiers.values())))
102
+ )
103
+ except (OSError, pa.ArrowException):
104
+ return {}
105
+ columns = {name: table.column(name).to_pylist() for name in SCHEMA.names}
106
+ runs: dict[tuple[str, int, str, int, int], RunResult] = {}
107
+ for row in zip(*(columns[name] for name in SCHEMA.names), strict=True):
108
+ values = dict(zip(SCHEMA.names, row, strict=True))
109
+ if identifiers.get(values["algorithm"]) != values["identifier"]:
110
+ continue
111
+ result = RunResult(
112
+ instance_id=values["instance_id"],
113
+ seed=values["seed"],
114
+ algorithm=values["algorithm"],
115
+ budget=values["budget"],
116
+ run_number=values["run_number"],
117
+ best=values["best"],
118
+ evaluations=values["evaluations"],
119
+ failed=values["failed"],
120
+ message=values["message"],
121
+ )
122
+ key = (
123
+ result.instance_id,
124
+ result.seed,
125
+ result.algorithm,
126
+ result.budget,
127
+ result.run_number,
128
+ )
129
+ runs.setdefault(key, result)
130
+ return runs
131
+
132
+ def save(
133
+ self,
134
+ results: Sequence[RunResult],
135
+ identifiers: Mapping[str, str],
136
+ stored_ids: Mapping[str, str] | None = None,
137
+ ) -> None:
138
+ """Write the runs of one task as a new Parquet file.
139
+
140
+ ``stored_ids`` maps an instance_id to the id under which its runs are
141
+ stored (for transformed instances and objectives of the user); other ids
142
+ are stored as they are.
143
+ """
144
+ ids = stored_ids or {}
145
+ if not results:
146
+ return
147
+ self.folder.mkdir(parents=True, exist_ok=True)
148
+ table = pa.table(
149
+ {
150
+ "instance_id": [
151
+ ids.get(result.instance_id, result.instance_id)
152
+ for result in results
153
+ ],
154
+ "seed": [result.seed for result in results],
155
+ "algorithm": [result.algorithm for result in results],
156
+ "identifier": [identifiers[result.algorithm] for result in results],
157
+ "budget": [result.budget for result in results],
158
+ "run_number": [result.run_number for result in results],
159
+ "ioh_version": [ioh_version()] * len(results),
160
+ "cache_format": [CACHE_FORMAT] * len(results),
161
+ "best": [result.best for result in results],
162
+ "evaluations": [result.evaluations for result in results],
163
+ "failed": [result.failed for result in results],
164
+ "message": [result.message for result in results],
165
+ },
166
+ schema=SCHEMA,
167
+ )
168
+ name = uuid.uuid4().hex
169
+ temporary = self.folder / f".{name}.parquet"
170
+ pq.write_table(table, temporary)
171
+ os.replace(temporary, self.folder / f"{name}.parquet")
172
+
173
+
174
+ def check_cache_dir(cache_dir: object) -> Path | None:
175
+ """Return cache_dir as a Path (None stays None); stop if it cannot be written."""
176
+ if cache_dir is None:
177
+ return None
178
+ if not isinstance(cache_dir, str | os.PathLike):
179
+ raise error(
180
+ f"cache_dir is a {type(cache_dir).__name__}.",
181
+ why="cache_dir must be the path of a folder, or None.",
182
+ fix='pass a path such as cache_dir=".rubberruler", or cache_dir=None.',
183
+ )
184
+ path = Path(cache_dir)
185
+ try:
186
+ path.mkdir(parents=True, exist_ok=True)
187
+ probe = path / f".write-check-{uuid.uuid4().hex}"
188
+ probe.write_bytes(b"")
189
+ probe.unlink()
190
+ except OSError as exception:
191
+ raise error(
192
+ f"cache_dir={str(path)!r} cannot be written "
193
+ f"({type(exception).__name__}: {exception}).",
194
+ why="rubberruler keeps the finished runs there, so that it can resume.",
195
+ fix="pass a folder that you can write to, or cache_dir=None to keep "
196
+ "nothing.",
197
+ ) from exception
198
+ return path
199
+
200
+
201
+ def identify(name: str, algorithm: object) -> Identity:
202
+ """The identifier of an algorithm: blake2b (16 bytes, hex) of its encoding."""
203
+ encoder = Encoder()
204
+ encoding = digest(b"algorithm", name.encode(), encoder.callable(algorithm, 0))
205
+ identifier = hashlib.blake2b(encoding, digest_size=16).hexdigest()
206
+ warning = None
207
+ if encoder.source_missing:
208
+ warning = f"{name}: cached by name only (source not available)"
209
+ elif encoder.value_skipped:
210
+ warning = (
211
+ f"{name}: cached by name and source only (some values could not be hashed)"
212
+ )
213
+ return Identity(identifier, warning)
214
+
215
+
216
+ class Encoder:
217
+ """Turns values into bytes by fixed rules per type (no pickle, no id or hash).
218
+
219
+ Each value becomes the blake2b digest of a type tag and the encodings of its
220
+ parts, so the result is the same in every process.
221
+ """
222
+
223
+ def __init__(self) -> None:
224
+ self.source_missing = False
225
+ self.value_skipped = False
226
+ self._seen: set[int] = set()
227
+ self._path: set[int] = set()
228
+
229
+ def value(self, value: object, depth: int) -> bytes:
230
+ """Encode a value by the rule for its type."""
231
+ if depth > MAX_DEPTH:
232
+ return self.skip(value)
233
+ if isinstance(value, np.generic):
234
+ return digest(b"numpy", str(value.dtype).encode(), repr(value).encode())
235
+ if value is None or isinstance(value, bool | int | float | str | bytes):
236
+ return digest(type(value).__name__.encode(), repr(value).encode())
237
+ if isinstance(value, np.ndarray):
238
+ if value.dtype.hasobject:
239
+ return self.skip(value)
240
+ return digest(
241
+ b"ndarray",
242
+ str(value.dtype).encode(),
243
+ repr(value.shape).encode(),
244
+ np.ascontiguousarray(value).tobytes(),
245
+ )
246
+ if isinstance(value, types.ModuleType):
247
+ return self.library(value.__name__, "")
248
+ if isinstance(value, list | tuple | dict | set | frozenset):
249
+ return self.container(value, depth)
250
+ if callable(value):
251
+ return self.callable(value, depth)
252
+ return self.skip(value)
253
+
254
+ def container(
255
+ self,
256
+ value: list[object]
257
+ | tuple[object, ...]
258
+ | dict[object, object]
259
+ | set[object]
260
+ | frozenset[object],
261
+ depth: int,
262
+ ) -> bytes:
263
+ if id(value) in self._path:
264
+ return self.skip(value)
265
+ self._path.add(id(value))
266
+ try:
267
+ if isinstance(value, dict):
268
+ parts = sorted(
269
+ self.value(key, depth + 1) + self.value(item, depth + 1)
270
+ for key, item in value.items()
271
+ )
272
+ elif isinstance(value, set | frozenset):
273
+ parts = sorted(self.value(item, depth + 1) for item in value)
274
+ else:
275
+ parts = [self.value(item, depth + 1) for item in value]
276
+ finally:
277
+ self._path.discard(id(value))
278
+ return digest(type(value).__name__.encode(), *parts)
279
+
280
+ def callable(self, function: object, depth: int) -> bytes:
281
+ """A function, partial, bound method, class, or callable object."""
282
+ if depth > MAX_DEPTH:
283
+ return self.skip(function)
284
+ if isinstance(function, functools.partial):
285
+ return digest(
286
+ b"partial",
287
+ self.callable(function.func, depth + 1),
288
+ self.value(function.args, depth + 1),
289
+ self.value(function.keywords, depth + 1),
290
+ )
291
+ if isinstance(function, types.MethodType):
292
+ return digest(
293
+ b"method",
294
+ self.callable(function.__func__, depth + 1),
295
+ self.state(function.__self__, depth + 1),
296
+ )
297
+ module = getattr(function, "__module__", None)
298
+ module = module if isinstance(module, str) else None
299
+ qualname = str(getattr(function, "__qualname__", type(function).__qualname__))
300
+ if isinstance(function, types.FunctionType):
301
+ if module is not None and is_library(module):
302
+ return self.library(module, qualname)
303
+ return self.function(function, depth)
304
+ if isinstance(function, type):
305
+ if module is not None and is_library(module):
306
+ return self.library(module, qualname)
307
+ return self.user_class(function, depth)
308
+ if isinstance(function, types.BuiltinFunctionType | types.BuiltinMethodType):
309
+ self.source_missing = True
310
+ return self.library(module or "builtins", qualname)
311
+ kind = type(function)
312
+ if is_library(kind.__module__):
313
+ return self.library(kind.__module__, kind.__qualname__)
314
+ return digest(
315
+ b"object",
316
+ self.callable(kind.__call__, depth + 1), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType]
317
+ self.state(function, depth + 1),
318
+ )
319
+
320
+ def function(self, function: types.FunctionType, depth: int) -> bytes:
321
+ """A user function: source, defaults, closure values, and global values."""
322
+ if id(function) in self._seen:
323
+ return digest(b"seen", function.__qualname__.encode())
324
+ self._seen.add(id(function))
325
+ try:
326
+ source = inspect.getsource(function)
327
+ except (OSError, TypeError, SyntaxError, IndexError, tokenize.TokenError):
328
+ # inspect reads the file of the code; another text there breaks tokenize.
329
+ self.source_missing = True
330
+ source = ""
331
+ code = function.__code__
332
+ closure = [
333
+ digest(b"cell", name.encode(), self.value(cell.cell_contents, depth + 1))
334
+ for name, cell in zip(
335
+ code.co_freevars, function.__closure__ or (), strict=True
336
+ )
337
+ if not _is_empty(cell)
338
+ ]
339
+ names = global_names(code)
340
+ references = [
341
+ digest(
342
+ b"global",
343
+ name.encode(),
344
+ self.global_value(function.__globals__[name], names, depth + 1),
345
+ )
346
+ for name in sorted(names)
347
+ if name in function.__globals__
348
+ ]
349
+ return digest(
350
+ b"function",
351
+ function.__qualname__.encode(),
352
+ source.encode(),
353
+ self.value(function.__defaults__, depth + 1),
354
+ self.value(function.__kwdefaults__, depth + 1),
355
+ digest(b"closure", *closure),
356
+ digest(b"globals", *references),
357
+ )
358
+
359
+ def global_value(self, value: object, names: set[str], depth: int) -> bytes:
360
+ """A global value; a user's module counts by the attributes the code names.
361
+
362
+ A function that calls ``helpers.step(x)`` names ``helpers`` and ``step``, so
363
+ a change of ``helpers.step`` changes the identifier, as for an imported
364
+ function. A library module counts by its version, as elsewhere.
365
+ """
366
+ if not isinstance(value, types.ModuleType) or is_library(value.__name__):
367
+ return self.value(value, depth)
368
+ attributes = vars(value)
369
+ return digest(
370
+ b"user module",
371
+ value.__name__.encode(),
372
+ *(
373
+ digest(b"attribute", name.encode(), self.value(attributes[name], depth))
374
+ for name in sorted(names)
375
+ if name in attributes
376
+ ),
377
+ )
378
+
379
+ def user_class(self, kind: type, depth: int) -> bytes:
380
+ """A user class: its bases and what its namespace holds, methods as functions.
381
+
382
+ The class's own source is not used: before Python 3.13, inspect looks for it in
383
+ the file of sys.modules[kind.__module__], which is another file after runpy.
384
+ """
385
+ if id(kind) in self._seen:
386
+ return digest(b"seen", kind.__qualname__.encode())
387
+ self._seen.add(id(kind))
388
+ bases = [self.callable(base, depth + 1) for base in kind.__bases__]
389
+ members = [
390
+ digest(b"member", name.encode(), self.member(value, depth + 1))
391
+ for name, value in sorted(vars(kind).items())
392
+ if name not in {"__dict__", "__weakref__"}
393
+ ]
394
+ return digest(
395
+ b"class",
396
+ kind.__qualname__.encode(),
397
+ digest(b"bases", *bases),
398
+ digest(b"members", *members),
399
+ )
400
+
401
+ def member(self, value: object, depth: int) -> bytes:
402
+ """A value in a class namespace; the functions inside descriptors count."""
403
+ if isinstance(value, staticmethod | classmethod):
404
+ return digest(b"wrapped", self.callable(value.__func__, depth + 1))
405
+ if isinstance(value, property):
406
+ parts = (value.fget, value.fset, value.fdel)
407
+ return digest(b"property", *(self.value(part, depth + 1) for part in parts))
408
+ return self.value(value, depth)
409
+
410
+ def state(self, instance: object, depth: int) -> bytes:
411
+ """The __dict__ of an object; objects without one cannot be encoded."""
412
+ try:
413
+ attributes = vars(instance)
414
+ except TypeError:
415
+ return self.skip(instance)
416
+ return self.value(dict(attributes), depth)
417
+
418
+ def library(self, module: str, qualname: str) -> bytes:
419
+ """A module or a library function or class: its top package and version."""
420
+ version = package_version(module)
421
+ if version is None:
422
+ self.value_skipped = True
423
+ version = "unknown"
424
+ return digest(b"library", version.encode(), qualname.encode())
425
+
426
+ def skip(self, value: object) -> bytes:
427
+ self.value_skipped = True
428
+ return digest(b"skipped", type(value).__qualname__.encode())
429
+
430
+
431
+ def digest(tag: bytes, *parts: bytes) -> bytes:
432
+ """blake2b of the tag and the parts, each with its length first."""
433
+ hasher = hashlib.blake2b(digest_size=32)
434
+ hasher.update(len(tag).to_bytes(8, "little") + tag)
435
+ for part in parts:
436
+ hasher.update(len(part).to_bytes(8, "little") + part)
437
+ return hasher.digest()
438
+
439
+
440
+ def global_names(code: types.CodeType) -> set[str]:
441
+ """co_names of the code object and of the code objects nested in it."""
442
+ names = set(code.co_names)
443
+ for constant in code.co_consts:
444
+ if isinstance(constant, types.CodeType):
445
+ names |= global_names(constant)
446
+ return names
447
+
448
+
449
+ def is_library(module: str | None) -> bool:
450
+ """Whether a module is in the standard library or an installed library.
451
+
452
+ A library is in site-packages, or in the folder of a distribution that an
453
+ installer put there, such as ``pip install --target`` or ``uv run --with`` do.
454
+ ``__main__`` is the user's script, even if ``sys.modules["__main__"]`` is later a
455
+ module in site-packages (as after runpy under ``python -m pytest``).
456
+ """
457
+ if module is None or module == "__main__":
458
+ return False
459
+ top = module.split(".")[0]
460
+ if top in sys.stdlib_module_names or top == "builtins":
461
+ return True
462
+ loaded = sys.modules.get(module) or sys.modules.get(top)
463
+ path = getattr(loaded, "__file__", None)
464
+ if not isinstance(path, str):
465
+ return False
466
+ real = os.path.realpath(path)
467
+ if any(real.startswith(folder + os.sep) for folder in library_folders()):
468
+ return True
469
+ return any(
470
+ root is not None and real.startswith(root + os.sep)
471
+ for root in map(installed_folder, _distributions().get(top, []))
472
+ )
473
+
474
+
475
+ @functools.cache
476
+ def installed_folder(distribution: str) -> str | None:
477
+ """Where an installer put this distribution, or None if it is the user's code.
478
+
479
+ A distribution without a RECORD (such as ``setup.py develop``) or installed as
480
+ editable is the user's own code, whose source must count.
481
+ """
482
+ try:
483
+ found = importlib.metadata.distribution(distribution)
484
+ except importlib.metadata.PackageNotFoundError:
485
+ return None
486
+ if found.read_text("RECORD") is None:
487
+ return None
488
+ try:
489
+ direct_url = json.loads(found.read_text("direct_url.json") or "{}")
490
+ except ValueError:
491
+ return None
492
+ dir_info = direct_url.get("dir_info") if isinstance(direct_url, dict) else None
493
+ if isinstance(dir_info, dict) and dir_info.get("editable"):
494
+ return None
495
+ return os.path.realpath(str(found.locate_file("")))
496
+
497
+
498
+ @functools.cache
499
+ def library_folders() -> tuple[str, ...]:
500
+ paths = sysconfig.get_paths()
501
+ folders = {paths[key] for key in ("stdlib", "platstdlib", "purelib", "platlib")}
502
+ folders.update(site.getsitepackages())
503
+ folders.add(site.getusersitepackages())
504
+ return tuple(sorted(os.path.realpath(folder) for folder in folders))
505
+
506
+
507
+ def package_version(module: str) -> str | None:
508
+ """ "name version" of the distribution with this top-level module, if any."""
509
+ top = module.split(".")[0]
510
+ if top in sys.stdlib_module_names or top == "builtins":
511
+ return f"python {platform.python_version()}"
512
+ for distribution in _distributions().get(top, []):
513
+ try:
514
+ return f"{distribution} {importlib.metadata.version(distribution)}"
515
+ except importlib.metadata.PackageNotFoundError:
516
+ continue
517
+ return None
518
+
519
+
520
+ @functools.cache
521
+ def ioh_version() -> str:
522
+ return importlib.metadata.version("ioh")
523
+
524
+
525
+ @functools.cache
526
+ def _distributions() -> Mapping[str, list[str]]:
527
+ return importlib.metadata.packages_distributions()
528
+
529
+
530
+ def _is_empty(cell: types.CellType) -> bool:
531
+ try:
532
+ cell.cell_contents # noqa: B018
533
+ except ValueError:
534
+ return True
535
+ return False