Coverage for moptipy / mock / components.py: 85%

523 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-24 08:49 +0000

1"""Generate random mock experiment parameters.""" 

2 

3from dataclasses import dataclass 

4from math import ceil, isfinite 

5from string import digits 

6from typing import Any, Final, Iterable 

7 

8from numpy.random import Generator 

9from pycommons.io.console import logger 

10from pycommons.types import check_int_range, type_error 

11 

12from moptipy.utils.nputils import rand_generator, rand_seeds_from_str 

13from moptipy.utils.strings import sanitize_name 

14 

15 

16def fixed_random_generator() -> Generator: 

17 """ 

18 Get the single, fixed random generator for the dummy experiment API. 

19 

20 :returns: the random number generator 

21 """ 

22 if not hasattr(fixed_random_generator, "gen"): 

23 setattr(fixed_random_generator, "gen", rand_generator(1)) 

24 return getattr(fixed_random_generator, "gen") 

25 

26 

27def _random_name(namelen: int, 

28 random: Generator = fixed_random_generator()) -> str: 

29 """ 

30 Generate a random name of a given length. 

31 

32 :param namelen: the length of the name 

33 :param random: a random number generator 

34 :returns: a name of the length 

35 """ 

36 check_int_range(namelen, "namelen", 1, 1_000) 

37 namer: Final[tuple[str, str, str]] = ("bcdfghjklmnpqrstvwxyz", "aeiou", 

38 digits) 

39 name = ["x"] * namelen 

40 index: int = 0 

41 n_done: bool = False 

42 for i in range(namelen): 

43 namee = namer[index] 

44 name[i] = namee[random.integers(len(namee))] 

45 n_done = n_done or (i == 2) 

46 if index <= 0: 

47 index += 1 

48 else: 

49 if (index == 2) and (int(random.integers(2)) <= 0): 

50 continue 

51 index = int((index + 1 + int(random.integers(2))) % len(namer)) 

52 if n_done: 

53 while index == 2: 

54 index = 1 - min(1, int(random.integers(6))) 

55 

56 return "".join(name) 

57 

58 

59def __append_not_allowed(forbidden, 

60 dest: set[str | float | int]) -> None: 

61 """ 

62 Append items to the set of not-allowed values. 

63 

64 :param forbidden: the forbidden elements 

65 :param dest: the set to append to 

66 """ 

67 if not forbidden: 

68 return 

69 if isinstance(forbidden, str | int | float): 

70 dest.add(forbidden) 

71 elif isinstance(forbidden, Instance): 

72 dest.add(forbidden.name) 

73 dest.add(forbidden.hardness) 

74 dest.add(forbidden.jitter) 

75 dest.add(forbidden.scale) 

76 dest.add(forbidden.best) 

77 dest.add(forbidden.worst) 

78 elif isinstance(forbidden, Algorithm): 

79 dest.add(forbidden.name) 

80 dest.add(forbidden.strength) 

81 dest.add(forbidden.jitter) 

82 elif isinstance(forbidden, Iterable): 

83 for item in forbidden: 

84 __append_not_allowed(item, dest) 

85 else: 

86 raise type_error(forbidden, "element to add", 

87 (str, int, float, Instance, Algorithm, Iterable)) 

88 

89 

90def _make_not_allowed(forbidden: Iterable[str | float | int | Iterable[ 

91 Any]] | None = None) -> set[str | float | int]: 

92 """ 

93 Create a set of not-allowed values. 

94 

95 :param forbidden: the forbidden elements 

96 :returns: the set of not-allowed values 

97 """ 

98 not_allowed: set[Any] = set() 

99 __append_not_allowed(forbidden, not_allowed) 

100 return not_allowed 

101 

102 

103@dataclass(frozen=True, init=False, order=True) 

104class Instance: 

105 """An immutable instance description record.""" 

106 

107 #: The instance name. 

108 name: str 

109 #: The instance hardness, in (0, 1), larger values are worst 

110 hardness: float 

111 #: The instance jitter, in (0, 1), larger values are worst 

112 jitter: float 

113 #: The instance scale, in (0, 1), larger values are worst 

114 scale: float 

115 #: The best (smallest) possible objective value 

116 best: int 

117 #: The worst (largest) possible objective value 

118 worst: int 

119 #: The set of attractors, i.e., local optima - including best and worst 

120 attractors: tuple[int, ...] 

121 

122 def __init__(self, 

123 name: str, 

124 hardness: float, 

125 jitter: float, 

126 scale: float, 

127 best: int, 

128 worst: int, 

129 attractors: tuple[int, ...]): 

130 """ 

131 Create a mock problem instance description. 

132 

133 :param name: the instance name 

134 :param hardness: the instance hardness 

135 :param jitter: the instance jitter 

136 :param scale: the instance scale 

137 :param best: the best (smallest) possible objective value 

138 :param worst: the worst (largest) possible objective value 

139 :param attractors: the set of attractors, i.e., local 

140 optima - including best and worst 

141 """ 

142 if not isinstance(name, str): 

143 raise type_error(name, "name", str) 

144 if name != sanitize_name(name): 

145 raise ValueError(f"Invalid name {name!r}.") 

146 object.__setattr__(self, "name", name) 

147 

148 if not isinstance(hardness, float): 

149 raise type_error(hardness, "hardness", float) 

150 if (not isfinite(hardness)) or (hardness <= 0) or (hardness >= 1): 

151 raise ValueError( 

152 f"hardness must be in (0, 1), but is {hardness}.") 

153 object.__setattr__(self, "hardness", hardness) 

154 

155 if not isinstance(jitter, float): 

156 raise type_error(jitter, "jitter", float) 

157 if (not isfinite(jitter)) or (jitter <= 0) or (jitter >= 1): 

158 raise ValueError( 

159 f"jitter must be in (0, 1), but is {jitter}.") 

160 object.__setattr__(self, "jitter", jitter) 

161 

162 if not isinstance(scale, float): 

163 raise type_error(scale, "scale", float) 

164 if (not isfinite(scale)) or (scale <= 0) or (scale >= 1): 

165 raise ValueError( 

166 f"scale must be in (0, 1), but is {scale}.") 

167 object.__setattr__(self, "scale", scale) 

168 object.__setattr__(self, "best", check_int_range( 

169 best, "best", 1, 1_000_000_000)) 

170 object.__setattr__(self, "worst", check_int_range( 

171 worst, "worst", best + 8, 1_000_000_000)) 

172 

173 if not isinstance(attractors, tuple): 

174 raise type_error(attractors, "attractors", tuple) 

175 if len(attractors) < 4: 

176 raise ValueError("attractors must contain at least 2 values," 

177 f" but contains only {len(attractors)}.") 

178 if attractors[0] != best: 

179 raise ValueError( 

180 f"attractors[0] must be {best}, but is {attractors[0]}") 

181 if attractors[-1] != worst: 

182 raise ValueError( 

183 f"attractors[-1] must be {worst}, but is {attractors[-1]}") 

184 prev = -1 

185 for att in attractors: 

186 check_int_range(att, "each attractor", max(best, prev + 1), worst) 

187 prev = att 

188 object.__setattr__(self, "attractors", attractors) 

189 

190 @staticmethod 

191 def create(n: int, 

192 forbidden: Any | None = None, 

193 random: Generator = fixed_random_generator()) \ 

194 -> tuple["Instance", ...]: 

195 """ 

196 Create a set of fixed problem instances. 

197 

198 :param n: the number of instances to generate 

199 :param random: a random number generator 

200 :param forbidden: the forbidden names and hardnesses 

201 :returns: a tuple of instances 

202 """ 

203 check_int_range(n, "n", 1, 1_000_000) 

204 

205 not_allowed: Final[set[str | float]] = \ 

206 _make_not_allowed(forbidden) 

207 names: list[str] = [] 

208 hardnesses: list[float] = [] 

209 jitters: list[float] = [] 

210 scales: list[float] = [] 

211 

212 # First we choose a unique name. 

213 max_name_len: int = int(max(2, ceil(n / 6))) 

214 trials: int = 0 

215 while len(names) < n: 

216 trials += 1 

217 if trials > 1000: 

218 trials = 1 

219 max_name_len += 1 

220 nv = _random_name(int(3 + random.integers(max_name_len)), random) 

221 if nv not in not_allowed: 

222 names.append(nv) 

223 not_allowed.add(nv) 

224 

225 # Now we pick an instance hardness. 

226 limit: int = 2000 

227 trials = 0 

228 while len(hardnesses) < n: 

229 v: float = -1 

230 while (v <= 0) or (v >= 1) or (not isfinite(v)): 

231 trials += 1 

232 if trials > 1000: 

233 trials = 0 

234 limit *= 2 

235 v = (int(random.uniform(1, limit)) / limit) \ 

236 if random.integers(4) <= 0 else \ 

237 (int(random.normal(loc=0.5, scale=0.25) * limit) / limit) 

238 if v not in not_allowed: 

239 hardnesses.append(v) 

240 not_allowed.add(v) 

241 

242 # Now we pick an instance jitter. 

243 limit = 2000 

244 trials = 0 

245 while len(jitters) < n: 

246 v = -1 

247 while (v <= 0) or (v >= 1) or (not isfinite(v)): 

248 trials += 1 

249 if trials > 1000: 

250 trials = 0 

251 limit *= 2 

252 v = (int(random.uniform(1, limit)) / limit) \ 

253 if random.integers(4) <= 0 else \ 

254 (int(random.normal(loc=0.5, scale=0.2) * limit) / limit) 

255 if v not in not_allowed: 

256 jitters.append(v) 

257 not_allowed.add(v) 

258 

259 # Now we choose a scale. 

260 limit = 2000 

261 trials = 0 

262 while len(scales) < n: 

263 v = -1 

264 while (v <= 0) or (v >= 1) or (not isfinite(v)): 

265 trials += 1 

266 if trials > 1000: 

267 trials = 0 

268 limit *= 2 

269 v = (int(random.uniform(1, limit)) / limit) \ 

270 if random.integers(4) <= 0 else \ 

271 (int(random.normal(loc=0.5, scale=0.2) * limit) / limit) 

272 if v not in not_allowed: 

273 scales.append(v) 

274 not_allowed.add(v) 

275 

276 # We choose the global optimum and the worst objective value. 

277 trials = 0 

278 scale: int = 1000 

279 loc: int = 1000 

280 limits: list[int] = [] 

281 while len(limits) < (2 * n): 

282 tbound: int = -1 

283 while (tbound <= 0) or (tbound >= 1_000_000_000): 

284 trials += 1 

285 if trials > 1000: 

286 trials = 0 

287 scale += (scale // 3) 

288 loc *= 2 

289 tbound = int(random.normal( 

290 loc=random.integers(low=1, high=4) * loc, scale=scale)) 

291 permitted: bool = True 

292 for b in limits: 

293 if abs(b - tbound) <= 41: 

294 permitted = False 

295 break 

296 if permitted and (tbound not in not_allowed): 

297 limits.append(tbound) 

298 not_allowed.add(tbound) 

299 

300 result: list[Instance] = [] 

301 attdone: set[int] = set() 

302 

303 for i in range(n, 0, -1): 

304 trials = 0 

305 b1 = b2 = -1 

306 while trials < 10: 

307 trials += 1 

308 b1 = limits.pop(random.integers(i * 2)) 

309 b2 = limits.pop(random.integers(i * 2 - 1)) 

310 if b1 > b2: 

311 b1, b2 = b2, b1 

312 if i <= 1: 

313 break 

314 if (b1 * max(2.0, 0.4 * (10 - trials))) < b2: 

315 break 

316 limits.extend((b1, b2)) 

317 attdone.clear() 

318 attdone.add(b1) 

319 attdone.add(b2) 

320 

321 # Now we make sure that there are at least 6 attractors. 

322 trials = 0 

323 min_dist = max(7.0, 0.07 * (b2 - b1)) 

324 while ((len(attdone) < 6) or (random.integers(7) > 0)) \ 

325 and (trials < 20000): 

326 a = -1 

327 while ((a <= b1) or (a >= b2) or (a in attdone)) \ 

328 and (trials < 20000): 

329 trials += 1 

330 a = int(random.integers(low=b1 + 1, high=b2 - 1)) 

331 if (a <= b1) or (a >= b2): 

332 continue 

333 ok = True 

334 for aa in attdone: 

335 if abs(aa - a) < min_dist: 

336 ok = False 

337 break 

338 if ok: 

339 attdone.add(a) 

340 elif (trials % 1000) <= 0: 

341 min_dist = max(5, 0.5 * min_dist) 

342 

343 result.append(Instance( 

344 name=names.pop(random.integers(i)), 

345 hardness=hardnesses.pop(random.integers(i)), 

346 jitter=jitters.pop(random.integers(i)), 

347 scale=scales.pop(random.integers(i)), 

348 best=b1, 

349 worst=b2, 

350 attractors=tuple(sorted(attdone)))) 

351 result.sort() 

352 logger(f"finished creating {n} instances.") 

353 return tuple(result) 

354 

355 

356@dataclass(frozen=True, init=False, order=True) 

357class Algorithm: 

358 """An immutable algorithm description record.""" 

359 

360 #: The algorithm name. 

361 name: str 

362 #: The algorithm strength, in (0, 1), larger values are worst 

363 strength: float 

364 #: The algorithm jitter, in (0, 1), larger values are worst 

365 jitter: float 

366 #: The algorithm complexity, in (0, 1), larger values are worst 

367 complexity: float 

368 

369 def __init__(self, 

370 name: str, 

371 strength: float, 

372 jitter: float, 

373 complexity: float): 

374 """ 

375 Create a mock algorithm description record. 

376 

377 :param name: the algorithm name 

378 :param strength: the algorithm strength 

379 :param jitter: the algorithm jitter 

380 :param complexity: the algorithm complexity 

381 """ 

382 if not isinstance(name, str): 

383 raise type_error(name, "name", str) 

384 if name != sanitize_name(name): 

385 raise ValueError(f"Invalid name {name!r}.") 

386 object.__setattr__(self, "name", name) 

387 

388 if not isinstance(strength, float): 

389 raise type_error(strength, "strength", float) 

390 if (not isfinite(strength)) or (strength <= 0) or (strength >= 1): 

391 raise ValueError( 

392 f"strength must be in (0, 1), but is {strength}.") 

393 object.__setattr__(self, "strength", strength) 

394 

395 if not isinstance(jitter, float): 

396 raise type_error(jitter, "jitter", float) 

397 if (not isfinite(jitter)) or (jitter <= 0) or (jitter >= 1): 

398 raise ValueError( 

399 f"jitter must be in (0, 1), but is {jitter}.") 

400 object.__setattr__(self, "jitter", jitter) 

401 

402 if not isinstance(complexity, float): 

403 raise type_error(complexity, "complexity", float) 

404 if (not isfinite(complexity)) or (complexity <= 0) \ 

405 or (complexity >= 1): 

406 raise ValueError( 

407 f"complexity must be in (0, 1), but is {complexity}.") 

408 object.__setattr__(self, "complexity", complexity) 

409 

410 @staticmethod 

411 def create(n: int, 

412 forbidden: Any | None = None, 

413 random: Generator = fixed_random_generator()) \ 

414 -> tuple["Algorithm", ...]: 

415 """ 

416 Create a set of fixed mock algorithms. 

417 

418 :param n: the number of algorithms to generate 

419 :param random: a random number generator 

420 :param forbidden: the forbidden names and strengths and so on 

421 :returns: a tuple of algorithms 

422 """ 

423 check_int_range(n, "n", 1) 

424 logger(f"now creating {n} algorithms.") 

425 

426 not_allowed: Final[set[str | float]] = \ 

427 _make_not_allowed(forbidden) 

428 names: list[str] = [] 

429 strengths: list[float] = [] 

430 jitters: list[float] = [] 

431 complexities: list[float] = [] 

432 

433 prefixes: Final[tuple[str, ...]] = ("aco", "bobyqa", "cmaes", "de", 

434 "ea", "eda", "ga", "gp", "hc", 

435 "ma", "pso", "rs", "rw", "sa", 

436 "umda") 

437 suffixes: Final[tuple[str, ...]] = ("1swap", "2swap", "µ") # noqa 

438 

439 max_name_len: int = int(max(2, ceil(n / 6))) 

440 trials: int = 0 

441 while len(names) < n: 

442 trials += 1 

443 if trials > 1000: 

444 trials = 1 

445 max_name_len += 1 

446 

447 name_mode = random.integers(5) 

448 if name_mode < 2: 

449 nva = _random_name(int(3 + random.integers(max_name_len)), 

450 random) 

451 if nva in not_allowed: 

452 continue 

453 if name_mode == 1: 

454 nvb = suffixes[random.integers(len(suffixes))] 

455 nv = f"{nva}_{nvb}" 

456 else: 

457 nv = nva 

458 if nv in not_allowed: 

459 continue 

460 not_allowed.add(nva) 

461 not_allowed.add(nv) 

462 names.append(nv) 

463 continue 

464 

465 nva = prefixes[random.integers(len(prefixes))] 

466 if name_mode == 3: 

467 nvb = _random_name(int(3 + random.integers( 

468 max_name_len)), random) 

469 if nvb in not_allowed: 

470 continue 

471 nv = f"{nva}_{nvb}" 

472 elif name_mode == 4: 

473 nvb = suffixes[random.integers(len(suffixes))] 

474 nv = f"{nva}_{nvb}" 

475 else: 

476 nv = nva 

477 nvb = "" 

478 

479 if nv in not_allowed: 

480 continue 

481 names.append(nv) 

482 not_allowed.add(nv) 

483 if name_mode == 3: 

484 not_allowed.add(nvb) 

485 

486 limit: int = 2000 

487 trials = 0 

488 while len(strengths) < n: 

489 v: float = -1 

490 while (v <= 0) or (v >= 1) or (not isfinite(v)): 

491 trials += 1 

492 if trials > 1000: 

493 trials = 0 

494 limit *= 2 

495 v = (int(random.uniform(1, limit)) / limit) \ 

496 if random.integers(4) <= 0 else \ 

497 (int(random.normal(loc=0.5, scale=0.25) * limit) / limit) 

498 if v not in not_allowed: 

499 strengths.append(v) 

500 not_allowed.add(v) 

501 

502 limit = 2000 

503 trials = 0 

504 while len(jitters) < n: 

505 v = -1 

506 while (v <= 0) or (v >= 1) or (not isfinite(v)): 

507 trials += 1 

508 if trials > 1000: 

509 trials = 0 

510 limit *= 2 

511 v = (int(random.uniform(1, limit)) / limit) \ 

512 if random.integers(4) <= 0 else \ 

513 (int(random.normal(loc=0.5, scale=0.2) * limit) / limit) 

514 if v not in not_allowed: 

515 jitters.append(v) 

516 not_allowed.add(v) 

517 

518 limit = 2000 

519 trials = 0 

520 while len(complexities) < n: 

521 v = -1 

522 while (v <= 0) or (v >= 1) or (not isfinite(v)): 

523 trials += 1 

524 if trials > 1000: 

525 trials = 0 

526 limit *= 2 

527 v = (int(random.uniform(1, limit)) / limit) \ 

528 if random.integers(4) <= 0 else \ 

529 (int(random.normal(loc=0.5, scale=0.2) * limit) / limit) 

530 if v not in not_allowed: 

531 complexities.append(v) 

532 not_allowed.add(v) 

533 

534 result: list[Algorithm] = [ 

535 Algorithm( 

536 name=names.pop(random.integers(i)), 

537 strength=strengths.pop(random.integers(i)), 

538 jitter=jitters.pop(random.integers(i)), 

539 complexity=complexities.pop(random.integers(i))) 

540 for i in range(n, 0, -1)] 

541 result.sort() 

542 

543 logger(f"finished creating {n} algorithms.") 

544 return tuple(result) 

545 

546 

547@dataclass(frozen=True, init=False, order=True) 

548class BasePerformance: 

549 """An algorithm applied to a problem instance description record.""" 

550 

551 #: the algorithm. 

552 algorithm: Algorithm 

553 #: the problem instance 

554 instance: Instance 

555 #: the base performance, in (0, 1), larger values are worst 

556 performance: float 

557 #: the performance jitter, in (0, 1), larger values are worst 

558 jitter: float 

559 #: the time per FE, in (0, 1), larger values are worst 

560 speed: float 

561 

562 def __init__(self, 

563 algorithm: Algorithm, 

564 instance: Instance, 

565 performance: float, 

566 jitter: float, 

567 speed: float): 

568 """ 

569 Create a mock algorithm-instance application description record. 

570 

571 :param algorithm: the algorithm 

572 :param instance: the instance 

573 :param performance: the base performance 

574 :param jitter: the performance jitter 

575 :param speed: the time required per FE 

576 """ 

577 if not isinstance(algorithm, Algorithm): 

578 raise type_error(algorithm, "algorithm", Algorithm) 

579 object.__setattr__(self, "algorithm", algorithm) 

580 if not isinstance(instance, Instance): 

581 raise type_error(instance, "instance", Instance) 

582 object.__setattr__(self, "instance", instance) 

583 

584 if not isinstance(performance, float): 

585 raise type_error(performance, "performance", float) 

586 if (not isfinite(performance)) or (performance <= 0) \ 

587 or (performance >= 1): 

588 raise ValueError( 

589 f"performance must be in (0, 1), but is {performance}.") 

590 object.__setattr__(self, "performance", performance) 

591 

592 if not isinstance(jitter, float): 

593 raise type_error(jitter, "jitter", float) 

594 if (not isfinite(jitter)) or (jitter <= 0) or (jitter >= 1): 

595 raise ValueError( 

596 f"jitter must be in (0, 1), but is {jitter}.") 

597 object.__setattr__(self, "jitter", jitter) 

598 

599 if not isinstance(speed, float): 

600 raise type_error(speed, "speed", float) 

601 if (not isfinite(speed)) or (speed <= 0) or (speed >= 1): 

602 raise ValueError( 

603 f"speed must be in (0, 1), but is {speed}.") 

604 object.__setattr__(self, "speed", speed) 

605 

606 @staticmethod 

607 def create(instance: Instance, 

608 algorithm: Algorithm, 

609 random: Generator = fixed_random_generator()) \ 

610 -> "BasePerformance": 

611 """ 

612 Compute the basic performance of an algorithm on a problem instance. 

613 

614 :param instance: the instance tuple 

615 :param algorithm: the algorithm tuple 

616 :param random: the random number generator 

617 :returns: a tuple of the performance in (0, 1); bigger values are 

618 worse, and a jitter in (0, 1), where bigger values are worse 

619 """ 

620 if not isinstance(instance, Instance): 

621 raise type_error(instance, "instance", Instance) 

622 if not isinstance(algorithm, Algorithm): 

623 raise type_error(algorithm, "algorithm", Algorithm) 

624 logger("now creating base performance for algorithm " 

625 f"{algorithm.name} on instance {instance.name}.") 

626 

627 perf: float = -1 

628 granularity: Final[int] = 2000 

629 while (perf <= 0) or (perf >= 1): 

630 perf = random.uniform(low=0, high=1) \ 

631 if random.integers(20) <= 0 else \ 

632 random.normal( 

633 loc=0.5 * (instance.hardness + algorithm.strength), 

634 scale=0.2 * (instance.jitter + algorithm.jitter)) 

635 perf = int(perf * granularity) / granularity 

636 

637 jit: float = -1 

638 while (jit <= 0) or (jit >= 1): 

639 jit = random.uniform(low=0, high=1) \ 

640 if random.integers(15) <= 0 else \ 

641 random.normal( 

642 loc=0.5 * (instance.jitter + algorithm.jitter), 

643 scale=0.2 * (instance.jitter + algorithm.jitter)) 

644 jit = int(jit * granularity) / granularity 

645 

646 speed: float = -1 

647 while (speed <= 0) or (speed >= 1): 

648 speed = random.uniform(low=0, high=1) \ 

649 if random.integers(20) <= 0 else \ 

650 random.normal( 

651 loc=0.5 * (instance.scale + algorithm.complexity), 

652 scale=0.2 * (instance.scale + algorithm.complexity)) 

653 speed = int(speed * granularity) / granularity 

654 

655 bp: Final[BasePerformance] = BasePerformance(algorithm=algorithm, 

656 instance=instance, 

657 performance=perf, 

658 jitter=jit, 

659 speed=speed) 

660 logger("finished base performance " 

661 f"{bp.algorithm.name}@{bp.instance.name}.") 

662 return bp 

663 

664 

665def get_run_seeds(instance: Instance, n_runs: int) -> tuple[int, ...]: 

666 """ 

667 Get the seeds for the runs. 

668 

669 :param instance: the mock instance 

670 :param n_runs: the number of runs 

671 :returns: a tuple of seeds 

672 """ 

673 if not isinstance(instance, Instance): 

674 raise type_error(instance, "instance", Instance) 

675 check_int_range(n_runs, "n_runs", 1, 1_000_000) 

676 res: Final[tuple[int, ...]] = tuple(sorted(rand_seeds_from_str( 

677 string=instance.name, n_seeds=n_runs))) 

678 logger(f"finished creating {n_runs} seeds for instance {instance.name}.") 

679 return res 

680 

681 

682@dataclass(frozen=True, init=False, order=True) 

683class Experiment: 

684 """An immutable experiment description.""" 

685 

686 #: The instances. 

687 instances: tuple[Instance, ...] 

688 #: The algorithms. 

689 algorithms: tuple[Algorithm, ...] 

690 #: The applications of the algorithms to the instances. 

691 applications: tuple[BasePerformance, ...] 

692 #: The random seeds per instance. 

693 per_instance_seeds: tuple[tuple[int, ...]] 

694 #: the seeds per instance 

695 __seeds_per_inst: dict[str | Instance, tuple[int, ...]] 

696 #: the performance per algorithm 

697 __perf_per_algo: dict[str | Algorithm, tuple[BasePerformance, ...]] 

698 #: the performance per instance 

699 __perf_per_inst: dict[str | Instance, tuple[BasePerformance, ...]] 

700 #: the algorithm by names 

701 __algo_by_name: dict[str, Algorithm] 

702 #: the algorithm names 

703 algorithm_names: tuple[str, ...] 

704 #: the instance by names 

705 __inst_by_name: dict[str, Instance] 

706 #: the instance names 

707 instance_names: tuple[str, ...] 

708 

709 def __init__(self, 

710 instances: tuple[Instance, ...], 

711 algorithms: tuple[Algorithm, ...], 

712 applications: tuple[BasePerformance, ...], 

713 per_instance_seeds: tuple[tuple[int, ...], ...]): 

714 """ 

715 Create a mock experiment definition. 

716 

717 :param algorithms: the algorithms 

718 :param instances: the instances 

719 :param applications: the applications of the algorithms to the 

720 instances 

721 :param per_instance_seeds: the seeds 

722 """ 

723 if not isinstance(instances, tuple): 

724 raise type_error(instances, "instances", tuple) 

725 if len(instances) <= 0: 

726 raise ValueError("instances must not be empty.") 

727 inst_bn: dict[str, Instance] = {} 

728 for a in instances: 

729 if not isinstance(a, Instance): 

730 raise type_error(a, "element of instances", Instance) 

731 if a.name in inst_bn: 

732 raise ValueError(f"double instance name {a.name}.") 

733 inst_bn[a.name] = a 

734 object.__setattr__(self, "instances", instances) 

735 object.__setattr__(self, "_Experiment__inst_by_name", inst_bn) 

736 object.__setattr__(self, "instance_names", 

737 tuple(sorted(inst_bn.keys()))) 

738 

739 if not isinstance(algorithms, tuple): 

740 raise type_error(algorithms, "algorithms", tuple) 

741 if len(algorithms) <= 0: 

742 raise ValueError("algorithms must not be empty.") 

743 algo_bn: dict[str, Algorithm] = {} 

744 for b in algorithms: 

745 if not isinstance(b, Algorithm): 

746 raise type_error(b, "element of algorithms", Algorithm) 

747 if b.name in algo_bn: 

748 raise ValueError(f"double algorithm name {b.name}.") 

749 if b.name in inst_bn: 

750 raise ValueError(f"instance/algorithm name {b.name} clash.") 

751 algo_bn[b.name] = b 

752 object.__setattr__(self, "algorithms", algorithms) 

753 object.__setattr__(self, "_Experiment__algo_by_name", algo_bn) 

754 object.__setattr__(self, "algorithm_names", 

755 tuple(sorted(algo_bn.keys()))) 

756 

757 if not isinstance(applications, tuple): 

758 raise type_error(applications, "applications", tuple) 

759 if len(applications) != len(algorithms) * len(instances): 

760 raise ValueError( 

761 f"There must be {len(algorithms) * len(instances)} " 

762 f"applications, but found {len(applications)}.") 

763 

764 perf_per_inst: dict[Instance, list[BasePerformance]] = {} 

765 perf_per_algo: dict[Algorithm, list[BasePerformance]] = {} 

766 

767 done: set[str] = set() 

768 for c in applications: 

769 if not isinstance(c, BasePerformance): 

770 raise type_error(c, "element of applications", BasePerformance) 

771 s = c.algorithm.name + "+" + c.instance.name 

772 if s in done: 

773 raise ValueError(f"Encountered application {s} twice.") 

774 done.add(s) 

775 if c.algorithm in perf_per_algo: 

776 perf_per_algo[c.algorithm].append(c) 

777 else: 

778 perf_per_algo[c.algorithm] = [c] 

779 if c.instance in perf_per_inst: 

780 perf_per_inst[c.instance].append(c) 

781 else: 

782 perf_per_inst[c.instance] = [c] 

783 object.__setattr__(self, "applications", applications) 

784 

785 pa: dict[str | Algorithm, tuple[BasePerformance, ...]] = {} 

786 for ax in algorithms: 

787 lax: list[BasePerformance] = perf_per_algo[ax] 

788 lax.sort() 

789 pa[ax.name] = pa[ax] = tuple(lax) 

790 pi: dict[str | Instance, tuple[BasePerformance, ...]] = {} 

791 for ix in instances: 

792 lix: list[BasePerformance] = perf_per_inst[ix] 

793 lix.sort() 

794 pi[ix.name] = pi[ix] = tuple(lix) 

795 object.__setattr__(self, "_Experiment__perf_per_algo", pa) 

796 object.__setattr__(self, "_Experiment__perf_per_inst", pi) 

797 

798 if not isinstance(per_instance_seeds, tuple): 

799 raise type_error(per_instance_seeds, "per_instance_seeds", tuple) 

800 if len(per_instance_seeds) != len(instances): 

801 raise ValueError( 

802 f"There must be one entry for each of the {len(instances)} " 

803 "instances, but per_instance_seeds only " 

804 f"has {len(per_instance_seeds)}.") 

805 xl: int = -1 

806 inst_seeds: Final[dict[str | Instance, tuple[int, ...]]] = {} 

807 for idx, d in enumerate(per_instance_seeds): 

808 if not isinstance(d, tuple): 

809 raise type_error(d, "element of per_instance_seeds", tuple) 

810 if len(d) <= 0: 

811 raise ValueError(f"there must be at least one per " 

812 f"instance seed, but found {len(d)}.") 

813 if xl < 0: 

814 xl = len(d) 

815 if len(d) != xl: 

816 raise ValueError(f"there must be {xl} per " 

817 f"instance seeds, but found {len(d)}.") 

818 for e in d: 

819 if not isinstance(e, int): 

820 raise type_error(e, "element of seeds", int) 

821 inst_seeds[instances[idx]] = d 

822 inst_seeds[instances[idx].name] = d 

823 object.__setattr__(self, "per_instance_seeds", per_instance_seeds) 

824 object.__setattr__(self, "_Experiment__seeds_per_inst", inst_seeds) 

825 

826 @staticmethod 

827 def create(n_instances: int, 

828 n_algorithms: int, 

829 n_runs: int, 

830 random: Generator = fixed_random_generator()) -> "Experiment": 

831 """ 

832 Create an experiment definition. 

833 

834 :param n_instances: the number of instances 

835 :param n_algorithms: the number of algorithms 

836 :param n_runs: the number of per-instance runs 

837 :param random: the random number generator to use 

838 """ 

839 check_int_range(n_instances, "n_instances", 1, 1_000_000) 

840 check_int_range(n_algorithms, "n_algorithms", 1, 1_000_000) 

841 check_int_range(n_runs, "n_runs", 1, 1_000_000) 

842 logger(f"now creating mock experiment with {n_algorithms} algorithms " 

843 f"on {n_instances} instances for {n_runs} runs.") 

844 

845 insts = Instance.create(n_instances, random=random) 

846 algos = Algorithm.create(n_algorithms, forbidden=insts, random=random) 

847 app = [BasePerformance.create(i, a, random) 

848 for i in insts for a in algos] 

849 app.sort() 

850 seeds = [get_run_seeds(i, n_runs) for i in insts] 

851 res: Final[Experiment] = Experiment(instances=insts, 

852 algorithms=algos, 

853 applications=tuple(app), 

854 per_instance_seeds=tuple(seeds)) 

855 logger(f"finished creating mock experiment with {len(res.instances)} " 

856 f"instances, {len(res.algorithms)} algorithms, and " 

857 f"{len(res.per_instance_seeds[0])} runs per instance-" 

858 f"algorithm combination.") 

859 return res 

860 

861 def seeds_for_instance(self, instance: str | Instance) \ 

862 -> tuple[int, ...]: 

863 """ 

864 Get the seeds for the specified instance. 

865 

866 :param instance: the instance 

867 :returns: the seeds 

868 """ 

869 return self.__seeds_per_inst[instance] 

870 

871 def instance_applications(self, instance: str | Instance) \ 

872 -> tuple[BasePerformance, ...]: 

873 """ 

874 Get the applications of the algorithms to a specific instance. 

875 

876 :param instance: the instance 

877 :returns: the applications 

878 """ 

879 return self.__perf_per_inst[instance] 

880 

881 def algorithm_applications(self, algorithm: str | Algorithm) \ 

882 -> tuple[BasePerformance, ...]: 

883 """ 

884 Get the applications of an algorithm to the instances. 

885 

886 :param algorithm: the algorithm 

887 :returns: the applications 

888 """ 

889 return self.__perf_per_algo[algorithm] 

890 

891 def get_algorithm(self, name: str) -> Algorithm: 

892 """ 

893 Get an algorithm by name. 

894 

895 :param name: the algorithm name 

896 :returns: the algorithm instance 

897 """ 

898 return self.__algo_by_name[name] 

899 

900 def get_instance(self, name: str) -> Instance: 

901 """ 

902 Get an instance by name. 

903 

904 :param name: the instance name 

905 :returns: the instance 

906 """ 

907 return self.__inst_by_name[name]