Coverage for britney2/installability/tester.py: 98%

340 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2026-08-18 12:43 +0000

1# Copyright (C) 2012 Niels Thykier <niels@thykier.net> 

2 

3# This program is free software; you can redistribute it and/or modify 

4# it under the terms of the GNU General Public License as published by 

5# the Free Software Foundation; either version 2 of the License, or 

6# (at your option) any later version. 

7 

8# This program is distributed in the hope that it will be useful, 

9# but WITHOUT ANY WARRANTY; without even the implied warranty of 

10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

11# GNU General Public License for more details. 

12 

13import logging 

14from collections import defaultdict 

15from collections.abc import Iterable, Iterator, MutableSet 

16from dataclasses import dataclass, field 

17from functools import partial 

18from itertools import chain, filterfalse 

19from typing import ( 

20 TYPE_CHECKING, 

21 Literal, 

22 cast, 

23) 

24 

25from more_itertools import iter_except 

26 

27from britney2.utils import add_transitive_dependencies_flatten 

28 

29if TYPE_CHECKING: 29 ↛ 30line 29 didn't jump to line 30 because the condition on line 29 was never true

30 from .. import BinaryPackageId 

31 from .universe import BinaryPackageUniverse 

32 

33 

34class InstallabilityTester: 

35 def __init__( 

36 self, universe: "BinaryPackageUniverse", suite_contents: set["BinaryPackageId"] 

37 ) -> None: 

38 """Create a new installability tester 

39 

40 suite_contents is a (mutable) set of package ids that determines 

41 which of the packages in universe are currently in the suite. 

42 

43 Package id: (pkg_name, pkg_version, pkg_arch) 

44 - NB: arch:all packages are "re-mapped" to given architecture. 

45 (simplifies caches and dependency checking) 

46 """ 

47 

48 self._universe = universe 

49 # FIXME: Move this field to TargetSuite 

50 self._suite_contents = suite_contents 

51 self._stats = InstallabilityStats() 

52 logger_name = ".".join((self.__class__.__module__, self.__class__.__name__)) 

53 self.logger = logging.getLogger(logger_name) 

54 

55 # Cache of packages known to be broken - we deliberately do not 

56 # include "broken" in it. 

57 self._cache_broken: set["BinaryPackageId"] = set() 

58 # Cache of packages known to be installable 

59 self._cache_inst: set["BinaryPackageId"] = set() 

60 # Per "arch" cache of the "minimal" (possibly incomplete) 

61 # pseudo-essential set. This includes all the packages that 

62 # are essential and packages that will always follow. 

63 # 

64 # It may not be a complete essential set, since alternatives 

65 # are not always resolved. Noticeably cases like "awk" may be 

66 # left out (since it could be either gawk, mawk or 

67 # original-awk) unless something in this sets depends strictly 

68 # on one of them 

69 self._cache_ess: dict[ 

70 str, 

71 tuple[ 

72 frozenset["BinaryPackageId"], 

73 frozenset["BinaryPackageId"], 

74 frozenset[frozenset["BinaryPackageId"]], 

75 ], 

76 ] = {} 

77 

78 essential_w_transitive_deps: MutableSet["BinaryPackageId"] = set( 

79 universe.essential_packages 

80 ) 

81 add_transitive_dependencies_flatten(universe, essential_w_transitive_deps) 

82 self._cache_essential_transitive_dependencies = essential_w_transitive_deps 

83 

84 def compute_installability(self) -> None: 

85 """Computes the installability of all the packages in the suite 

86 

87 This method computes the installability of all packages in 

88 the suite and caches the result. This has the advantage of 

89 making "is_installable" queries very fast for all packages 

90 in the suite. 

91 """ 

92 

93 universe = self._universe 

94 check_inst = self._check_inst 

95 cbroken = self._cache_broken 

96 cache_inst = self._cache_inst 

97 suite_contents = self._suite_contents 

98 tcopy = [x for x in suite_contents] 

99 for t in filterfalse(cache_inst.__contains__, tcopy): 

100 if t in cbroken: 100 ↛ 101line 100 didn't jump to line 101 because the condition on line 100 was never true

101 continue 

102 res = check_inst(t) 

103 if t in universe.equivalent_packages: 

104 if res: 

105 cache_inst.update( 

106 x 

107 for x in universe.packages_equivalent_to(t) 

108 if x in suite_contents 

109 ) 

110 else: 

111 eqv_set = universe.packages_equivalent_to(t) & suite_contents 

112 suite_contents -= eqv_set 

113 cbroken |= eqv_set 

114 

115 @property 

116 def stats(self) -> "InstallabilityStats": 

117 return self._stats 

118 

119 def any_of_these_are_in_the_suite(self, pkgs: Iterable["BinaryPackageId"]) -> bool: 

120 """Test if at least one package of a given set is in the suite 

121 

122 :param pkgs: A set of package ids (as defined in the constructor) 

123 :return: True if any of the packages in pkgs are currently in the suite 

124 """ 

125 return not self._suite_contents.isdisjoint(pkgs) 

126 

127 def is_pkg_in_the_suite(self, pkg_id: "BinaryPackageId") -> bool: 

128 """Test if the package of is in the suite 

129 

130 :param pkg_id: A package id (as defined in the constructor) 

131 :return: True if the pkg is currently in the suite 

132 """ 

133 return pkg_id in self._suite_contents 

134 

135 def which_of_these_are_in_the_suite( 

136 self, pkgs: Iterable["BinaryPackageId"] 

137 ) -> Iterator["BinaryPackageId"]: 

138 """Iterate over all packages that are in the suite 

139 

140 :param pkgs: An iterable of package ids 

141 :return: An iterable of package ids that are in the suite 

142 """ 

143 yield from (x for x in pkgs if x in self._suite_contents) 

144 

145 def add_binary(self, pkg_id: "BinaryPackageId") -> Literal[True]: 

146 """Add a binary package to the suite 

147 

148 If the package is not known, this method will throw an 

149 KeyError. 

150 """ 

151 

152 if pkg_id not in self._universe: # pragma: no cover 

153 raise KeyError(str(pkg_id)) 

154 

155 if pkg_id in self._universe.broken_packages: 

156 self._suite_contents.add(pkg_id) 

157 elif pkg_id not in self._suite_contents: 157 ↛ 174line 157 didn't jump to line 174 because the condition on line 157 was always true

158 self._suite_contents.add(pkg_id) 

159 if self._cache_inst: 

160 self._stats.cache_drops += 1 

161 self._cache_inst = set() 

162 if self._cache_broken: 

163 # Re-add broken packages as some of them may now be installable 

164 self._suite_contents |= self._cache_broken 

165 self._cache_broken = set() 

166 if ( 

167 pkg_id in self._cache_essential_transitive_dependencies 

168 and pkg_id.architecture in self._cache_ess 

169 ): 

170 # Adds new possibly pseudo-essential => "pseudo-essential" set needs to be 

171 # recomputed 

172 del self._cache_ess[pkg_id.architecture] 

173 

174 return True 

175 

176 def remove_binary(self, pkg_id: "BinaryPackageId") -> Literal[True]: 

177 """Remove a binary from the suite 

178 

179 :param pkg_id: The id of the package 

180 :raises KeyError: if the package is not known 

181 """ 

182 

183 if pkg_id not in self._universe: # pragma: no cover 

184 raise KeyError(str(pkg_id)) 

185 

186 self._cache_broken.discard(pkg_id) 

187 

188 if pkg_id in self._suite_contents: 

189 self._suite_contents.remove(pkg_id) 

190 if pkg_id.architecture in self._cache_ess: 

191 (start, ess_never, ess_choices) = self._cache_ess[pkg_id.architecture] 

192 if pkg_id in start or any(pkg_id in choices for choices in ess_choices): 

193 # Removes a package from the "pseudo-essential set" 

194 del self._cache_ess[pkg_id.architecture] 

195 

196 if not self._universe.reverse_dependencies_of(pkg_id): 

197 # no reverse relations - safe 

198 return True 

199 if ( 

200 pkg_id not in self._universe.broken_packages 

201 and pkg_id in self._cache_inst 

202 ): 

203 # It is in our cache (and not guaranteed to be broken) - throw out the cache 

204 self._cache_inst = set() 

205 self._stats.cache_drops += 1 

206 

207 return True 

208 

209 def is_installable(self, pkg_id: "BinaryPackageId") -> bool: 

210 """Test if a package is installable in this package set 

211 

212 The package is assumed to be in the suite and only packages in 

213 the suite can be used to satisfy relations. 

214 

215 :param pkg_id: The id of the package 

216 Returns True iff the package is installable. 

217 Returns False otherwise. 

218 """ 

219 

220 self._stats.is_installable_calls += 1 

221 

222 if pkg_id not in self._universe: # pragma: no cover 

223 raise KeyError(str(pkg_id)) 

224 

225 if ( 

226 pkg_id not in self._suite_contents 

227 or pkg_id in self._universe.broken_packages 

228 ): 

229 self._stats.cache_hits += 1 

230 return False 

231 

232 if pkg_id in self._cache_inst: 

233 self._stats.cache_hits += 1 

234 return True 

235 

236 self._stats.cache_misses += 1 

237 return self._check_inst(pkg_id) 

238 

239 def _check_inst( 

240 self, 

241 t: "BinaryPackageId", 

242 musts: set["BinaryPackageId"] | None = None, 

243 never: set["BinaryPackageId"] | None = None, 

244 choices: set[frozenset["BinaryPackageId"]] | None = None, 

245 ) -> bool: 

246 # See the explanation of musts, never and choices below. 

247 stats = self._stats 

248 universe = self._universe 

249 suite_contents = self._suite_contents 

250 cbroken = self._cache_broken 

251 

252 # Our installability verdict - start with "yes" and change if 

253 # prove otherwise. 

254 verdict = True 

255 

256 # set of packages that must be installed with this package 

257 if musts is None: 

258 musts = set() 

259 musts.add(t) 

260 # set of packages we can *never* choose (e.g. due to conflicts) 

261 if never is None: 

262 never = set() 

263 # set of relations were we have a choice, but where we have not 

264 # committed ourselves yet. Hopefully some choices may be taken 

265 # for us (if one of the alternatives appear in "musts") 

266 if choices is None: 

267 choices = set() 

268 

269 # The subset of musts we haven't checked yet. 

270 check = [t] 

271 

272 if len(musts) == 1: 

273 # Include the essential packages in the suite as a starting point. 

274 if t.architecture not in self._cache_ess: 

275 # The minimal essential set cache is not present - 

276 # compute it now. 

277 (start, ess_never, ess_choices) = self._get_min_pseudo_ess_set( 

278 t.architecture 

279 ) 

280 else: 

281 (start, ess_never, ess_choices) = self._cache_ess[t.architecture] 

282 

283 if t in ess_never: 

284 # t conflicts with something in the essential set or the essential 

285 # set conflicts with t - either way, t is f***ed 

286 cbroken.add(t) 

287 suite_contents.remove(t) 

288 stats.conflicts_essential += 1 

289 return False 

290 musts.update(start) 

291 never.update(ess_never) 

292 choices.update(ess_choices) 

293 

294 # curry check_loop 

295 check_loop = partial( 

296 self._check_loop, universe, suite_contents, stats, musts, never, cbroken 

297 ) 

298 

299 # Useful things to remember: 

300 # 

301 # * musts and never are disjointed at all times 

302 # - if not, t cannot be installable. Either t, or one of 

303 # its dependencies conflict with t or one of its (other) 

304 # dependencies. 

305 # 

306 # * choices should generally be avoided as much as possible. 

307 # - picking a bad choice requires backtracking 

308 # - sometimes musts/never will eventually "solve" the choice. 

309 # 

310 # * check never includes choices (these are always in choices) 

311 # 

312 # * A package is installable if never and musts are disjointed 

313 # and both check and choices are empty. 

314 # - exception: resolve_choices may determine the installability 

315 # of t via recursion (calls _check_inst). In this case 

316 # check and choices are not (always) empty. 

317 

318 def _prune_choices(rebuild: set[frozenset["BinaryPackageId"]]) -> bool: 

319 """Picks a choice from choices and updates rebuild. 

320 

321 Prunes the choices and updates "rebuild" to reflect the 

322 pruned choices. 

323 

324 Returns True if t is installable (determined via recursion). 

325 Returns False if a choice was picked and added to check. 

326 Returns None if t is uninstallable (no choice can be picked). 

327 

328 NB: If this returns False, choices should be replaced by 

329 rebuild. 

330 """ 

331 

332 assert musts is not None 

333 assert choices is not None 

334 assert never is not None 

335 # We already satisfied/chosen at least one of the literals 

336 # in the choice, so the choice is gone 

337 for choice in filter(musts.isdisjoint, choices): 

338 # cbroken is needed here because (in theory) it could 

339 # have changed since the choice was discovered and it 

340 # is smaller than suite_contents (so presumably faster) 

341 remain = choice - never - cbroken 

342 

343 if len(remain) == 1: 

344 # the choice was reduced to one package we haven't checked - check that 

345 check.extend(remain) 

346 musts.update(remain) 

347 stats.choice_presolved += 1 

348 continue 

349 

350 if not remain: 

351 # all alternatives would violate the conflicts or are uninstallable 

352 # => package is not installable 

353 stats.choice_presolved += 1 

354 return False 

355 

356 # The choice is still deferred 

357 rebuild.add(frozenset(remain)) 

358 

359 return True 

360 

361 # END _prune_choices 

362 

363 while check: 

364 if not check_loop(choices, check): 

365 verdict = False 

366 break 

367 

368 if choices: 

369 rebuild: set[frozenset["BinaryPackageId"]] = set() 

370 

371 if not _prune_choices(rebuild): 

372 verdict = False 

373 break 

374 

375 if not check and rebuild: 

376 # We have to "guess" now, which is always fun, but not cheap. We 

377 # stop guessing: 

378 # - once we run out of choices to make (obviously), OR 

379 # - if one of the choices exhaust all but one option 

380 if self.resolve_choices(check, musts, never, rebuild): 

381 # The recursive call have already updated the 

382 # cache so there is not point in doing it again. 

383 return True 

384 choices = rebuild 

385 

386 if verdict: 

387 # if t is installable, then so are all packages in musts 

388 self._cache_inst.update(musts) 

389 stats.solved_installable += 1 

390 else: 

391 stats.solved_uninstallable += 1 

392 

393 return verdict 

394 

395 def resolve_choices( 

396 self, 

397 check: list["BinaryPackageId"], 

398 musts: set["BinaryPackageId"], 

399 never: set["BinaryPackageId"], 

400 choices: set[frozenset["BinaryPackageId"]], 

401 ) -> bool: 

402 universe = self._universe 

403 suite_contents = self._suite_contents 

404 stats = self._stats 

405 cbroken = self._cache_broken 

406 

407 while choices: 

408 choice_options = choices.pop() 

409 

410 choice = iter(choice_options) 

411 last = next(choice) # pick one to go last 

412 solved = False 

413 for p in choice: 

414 musts_copy = musts.copy() 

415 never_tmp: set["BinaryPackageId"] = set() 

416 choices_tmp: set[frozenset["BinaryPackageId"]] = set() 

417 check_tmp = [p] 

418 # _check_loop assumes that "musts" is up to date 

419 musts_copy.add(p) 

420 if not self._check_loop( 

421 universe, 

422 suite_contents, 

423 stats, 

424 musts_copy, 

425 never_tmp, 

426 cbroken, 

427 choices_tmp, 

428 check_tmp, 

429 ): 

430 # p cannot be chosen/is broken (unlikely, but ...) 

431 continue 

432 

433 # Test if we can pick p without any consequences. 

434 # - when we can, we avoid a backtrack point. 

435 if never_tmp <= never and choices_tmp <= choices: 

436 # we can pick p without picking up new conflicts 

437 # or unresolved choices. Therefore we commit to 

438 # using p. 

439 musts.update(musts_copy) 

440 stats.choice_resolved_without_restore_point += 1 

441 solved = True 

442 break 

443 

444 if not musts.isdisjoint(never_tmp): 

445 # If we pick p, we will definitely end up making 

446 # t uninstallable, so p is a no-go. 

447 continue 

448 

449 stats.backtrace_restore_point_created += 1 

450 # We are not sure that p is safe, setup a backtrack 

451 # point and recurse. 

452 never_tmp |= never 

453 choices_tmp |= choices 

454 if self._check_inst(p, musts_copy, never_tmp, choices_tmp): 

455 # Success, p was a valid choice and made it all 

456 # installable 

457 return True 

458 

459 # If we get here, we failed to find something that 

460 # would satisfy choice (without breaking the 

461 # installability of t). This means p cannot be used 

462 # to satisfy the dependencies, so pretend to conflict 

463 # with it - hopefully it will reduce future choices. 

464 never.add(p) 

465 stats.backtrace_restore_point_used += 1 

466 

467 if not solved: 

468 # Optimization for the last case; avoid the recursive call 

469 # and just assume the last will lead to a solution. If it 

470 # doesn't there is no solution and if it does, we don't 

471 # have to back-track anyway. 

472 check.append(last) 

473 musts.add(last) 

474 stats.backtrace_last_option += 1 

475 return False 

476 return False 

477 

478 def _check_loop( 

479 self, 

480 universe: "BinaryPackageUniverse", 

481 suite_contents: set["BinaryPackageId"], 

482 stats: "InstallabilityStats", 

483 musts: set["BinaryPackageId"], 

484 never: set["BinaryPackageId"], 

485 cbroken: set["BinaryPackageId"], 

486 choices: set[frozenset["BinaryPackageId"]], 

487 check: list["BinaryPackageId"], 

488 ) -> bool: 

489 """Finds all guaranteed dependencies via "check". 

490 

491 If it returns False, t is not installable. If it returns True 

492 then "check" is exhausted. If "choices" are empty and this 

493 returns True, then t is installable. 

494 """ 

495 # Local variables for faster access... 

496 not_satisfied: partial[filter["BinaryPackageId"]] = partial( 

497 filter, musts.isdisjoint 

498 ) 

499 

500 # While we have guaranteed dependencies (in check), examine all 

501 # of them. 

502 for cur in iter_except(check.pop, IndexError): 

503 relations = universe.relations_of(cur) 

504 

505 if relations.negative_dependencies: 

506 # Conflicts? 

507 if cur in never: 

508 # cur adds a (reverse) conflict, so check if cur 

509 # is in never. 

510 # 

511 # - there is a window where two conflicting 

512 # packages can be in check. Example "A" depends 

513 # on "B" and "C". If "B" conflicts with "C", 

514 # then both "B" and "C" could end in "check". 

515 return False 

516 # We must install cur for the package to be installable, 

517 # so "obviously" we can never choose any of its conflicts 

518 never |= relations.negative_dependencies & suite_contents 

519 

520 # depgroup can be satisfied by picking something that is 

521 # already in musts - lets pick that (again). :) 

522 for depgroup in cast( 

523 set[frozenset["BinaryPackageId"]], 

524 not_satisfied(relations.dependencies), 

525 ): 

526 # Of all the packages listed in the relation remove those that 

527 # are either: 

528 # - not in the suite 

529 # - known to be broken (by cache) 

530 # - in never 

531 candidates = suite_contents & depgroup - never 

532 

533 if not candidates: 

534 # We got no candidates to satisfy it - this 

535 # package cannot be installed with the current 

536 # (version of the) suite 

537 if cur not in cbroken and depgroup.isdisjoint(never): 

538 # cur's dependency cannot be satisfied even if never was empty. 

539 # This means that cur itself is broken (as well). 

540 cbroken.add(cur) 

541 suite_contents.remove(cur) 

542 return False 

543 if len(candidates) == 1: 

544 # only one possible solution to this choice and we 

545 # haven't seen it before 

546 check.extend(candidates) 

547 musts |= candidates 

548 else: 

549 possible_eqv = candidates & universe.equivalent_packages 

550 if len(possible_eqv) > 1: 

551 # Exploit equivalency to reduce the number of 

552 # candidates if possible. Basically, this 

553 # code maps "similar" candidates into a single 

554 # candidate that will give a identical result 

555 # to any other candidate it eliminates. 

556 # 

557 # See InstallabilityTesterBuilder's 

558 # _build_eqv_packages_table method for more 

559 # information on how this works. 

560 new_cand = candidates - possible_eqv 

561 stats.eqv_table_times_used += 1 

562 

563 for chosen in iter_except(possible_eqv.pop, KeyError): 

564 new_cand.add(chosen) 

565 possible_eqv -= universe.packages_equivalent_to(chosen) 

566 stats.eqv_table_total_number_of_alternatives_eliminated += len( 

567 candidates 

568 ) - len(new_cand) 

569 if len(new_cand) == 1: 

570 check.extend(new_cand) 

571 musts |= new_cand 

572 stats.eqv_table_reduced_to_one += 1 

573 continue 

574 elif len(candidates) == len(new_cand): 

575 stats.eqv_table_reduced_by_zero += 1 

576 

577 candidates = new_cand 

578 # defer this choice till later 

579 choices.add(frozenset(candidates)) 

580 return True 

581 

582 def _get_min_pseudo_ess_set(self, arch: str) -> tuple[ 

583 frozenset["BinaryPackageId"], 

584 frozenset["BinaryPackageId"], 

585 frozenset[frozenset["BinaryPackageId"]], 

586 ]: 

587 if arch not in self._cache_ess: 587 ↛ 648line 587 didn't jump to line 648 because the condition on line 587 was always true

588 # The minimal essential set cache is not present - 

589 # compute it now. 

590 suite_contents = self._suite_contents 

591 cbroken = self._cache_broken 

592 universe = self._universe 

593 stats = self._stats 

594 

595 ess_base = [ 

596 x 

597 for x in self._universe.essential_packages 

598 if x.architecture == arch and x in suite_contents 

599 ] 

600 start = set(ess_base) 

601 ess_never: set["BinaryPackageId"] = set() 

602 ess_choices: set[frozenset["BinaryPackageId"]] = set() 

603 not_satisfied: partial[filter["BinaryPackageId"]] = partial( 

604 filter, start.isdisjoint 

605 ) 

606 

607 while ess_base: 

608 self._check_loop( 

609 universe, 

610 suite_contents, 

611 stats, 

612 start, 

613 ess_never, 

614 cbroken, 

615 ess_choices, 

616 ess_base, 

617 ) 

618 if ess_choices: 

619 # Try to break choices where possible 

620 nchoice = set() 

621 for choice in cast( 

622 set[frozenset["BinaryPackageId"]], not_satisfied(ess_choices) 

623 ): 

624 b = False 

625 for c in choice: 

626 relations = universe.relations_of(c) 

627 if ( 

628 relations.negative_dependencies is None 

629 or relations.negative_dependencies <= ess_never 

630 ) and not any(not_satisfied(relations.dependencies)): 

631 ess_base.append(c) 

632 b = True 

633 break 

634 if not b: 

635 nchoice.add(choice) 

636 ess_choices = nchoice 

637 else: 

638 break 

639 

640 for x in start: 

641 ess_never.update(universe.negative_dependencies_of(x)) 

642 self._cache_ess[arch] = ( 

643 frozenset(start), 

644 frozenset(ess_never), 

645 frozenset(ess_choices), 

646 ) 

647 

648 return self._cache_ess[arch] 

649 

650 def compute_stats(self) -> dict[str, "ArchStats"]: 

651 universe = self._universe 

652 graph_stats: dict[str, ArchStats] = defaultdict(ArchStats) 

653 seen_eqv: dict[str, set["BinaryPackageId"]] = defaultdict(set) 

654 

655 for pkg in universe: 

656 pkg_arch = pkg.architecture 

657 relations = universe.relations_of(pkg) 

658 arch_stats = graph_stats[pkg_arch] 

659 

660 arch_stats.nodes += 1 

661 

662 if pkg in universe.equivalent_packages and pkg not in seen_eqv[pkg_arch]: 

663 arch_stats.eqv_nodes += sum( 

664 1 

665 for e in universe.packages_equivalent_to(pkg) 

666 if e.architecture == pkg_arch 

667 ) 

668 

669 arch_stats.add_dep_edges(relations.dependencies) 

670 if relations.negative_dependencies is not None: 

671 arch_stats.add_con_edges(relations.negative_dependencies) 

672 

673 for stat in graph_stats.values(): 

674 stat.compute_all() 

675 

676 return graph_stats 

677 

678 

679@dataclass(slots=True) 

680class InstallabilityStats: 

681 cache_hits: int = 0 

682 cache_misses: int = 0 

683 cache_drops: int = 0 

684 backtrace_restore_point_created: int = 0 

685 backtrace_restore_point_used: int = 0 

686 backtrace_last_option: int = 0 

687 choice_presolved: int = 0 

688 choice_resolved_without_restore_point: int = 0 

689 is_installable_calls: int = 0 

690 solved_installable: int = 0 

691 solved_uninstallable: int = 0 

692 conflicts_essential: int = 0 

693 eqv_table_times_used: int = 0 

694 eqv_table_reduced_to_one: int = 0 

695 eqv_table_reduced_by_zero: int = 0 

696 eqv_table_total_number_of_alternatives_eliminated: int = 0 

697 

698 def stats(self) -> list[str]: 

699 return [ 

700 f"Requests - is_installable: {self.is_installable_calls}", 

701 f"Cache - hits: {self.cache_hits}, misses: {self.cache_misses}, drops: {self.cache_drops}", 

702 f"Choices - pre-solved: {self.choice_presolved}, No RP: {self.choice_resolved_without_restore_point}", 

703 f"Backtrace - RP created: {self.backtrace_restore_point_created}, RP used: {self.backtrace_restore_point_used}, reached last option: {self.backtrace_last_option}", # nopep8 

704 f"Solved - installable: {self.solved_installable}, uninstallable: {self.solved_uninstallable}, conflicts essential: {self.conflicts_essential}", # nopep8 

705 f"Eqv - times used: {self.eqv_table_times_used}, perfect reductions: {self.eqv_table_reduced_to_one}, failed reductions: {self.eqv_table_reduced_by_zero}, total no. of alternatives pruned: {self.eqv_table_total_number_of_alternatives_eliminated}", # nopep8 

706 ] 

707 

708 

709@dataclass(slots=True) 

710class _Stats: 

711 max: int = 0 

712 min: int = 0 

713 median: int = 0 

714 average: float = 0 

715 sum: int = 0 

716 size: int = 0 

717 average_per_node: float | None = None 

718 

719 def __str__(self) -> str: 

720 s = f"max: {self.max}, min: {self.min}, median: {self.median}, average: {self.average:.6f} ({self.sum}/{self.size})" 

721 if self.average_per_node is not None: 

722 s = f"{s}, average-per-node: {self.average_per_node:.6f}" 

723 return s 

724 

725 

726@dataclass(slots=True) 

727class ArchStats: 

728 nodes: int = 0 

729 eqv_nodes: int = 0 

730 dep_edges: list[frozenset[frozenset["BinaryPackageId"]]] = field( 

731 default_factory=list 

732 ) 

733 con_edges: list[frozenset["BinaryPackageId"]] = field(default_factory=list) 

734 dependency_clauses: _Stats = field(default_factory=_Stats) 

735 dependency_clause_alternatives: _Stats = field(default_factory=_Stats) 

736 negative_dependency_clauses: _Stats = field(default_factory=_Stats) 

737 

738 def stat_summary(self) -> list[str]: 

739 return [ 

740 f"nodes: {self.nodes}, eqv-nodes: {self.eqv_nodes}", 

741 f"dependency-clauses, {self.dependency_clauses}", 

742 f"dependency-clause-alternatives, {self.dependency_clause_alternatives}", 

743 f"negative-dependency-clauses, {self.negative_dependency_clauses}", 

744 ] 

745 

746 def add_dep_edges(self, edges: frozenset[frozenset["BinaryPackageId"]]) -> None: 

747 self.dep_edges.append(edges) 

748 

749 def add_con_edges(self, edges: frozenset["BinaryPackageId"]) -> None: 

750 self.con_edges.append(edges) 

751 

752 def _list_stats( 

753 self, stats: _Stats, sorted_list: list[int], average_per_node: bool = False 

754 ) -> None: 

755 if sorted_list: 

756 stats.max = sorted_list[-1] 

757 stats.min = sorted_list[0] 

758 stats.sum = sum(sorted_list) 

759 stats.size = len(sorted_list) 

760 stats.average = float(stats.sum) / len(sorted_list) 

761 stats.median = sorted_list[len(sorted_list) // 2] 

762 if average_per_node: 

763 stats.average_per_node = float(stats.sum) / self.nodes 

764 

765 def compute_all(self) -> None: 

766 dep_edges = self.dep_edges 

767 con_edges = self.con_edges 

768 sorted_no_dep_edges = sorted(len(x) for x in dep_edges) 

769 sorted_size_dep_edges = sorted(len(x) for x in chain.from_iterable(dep_edges)) 

770 sorted_no_con_edges = sorted(len(x) for x in con_edges) 

771 self._list_stats(self.dependency_clauses, sorted_no_dep_edges) 

772 self._list_stats( 

773 self.dependency_clause_alternatives, 

774 sorted_size_dep_edges, 

775 average_per_node=True, 

776 ) 

777 self._list_stats(self.negative_dependency_clauses, sorted_no_con_edges)