Coverage for moptipy / operators / permutations / op1_swap_exactly_n.py: 63%

133 statements  

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

1""" 

2An operator trying to change exactly `n` elements in a permutation. 

3 

4This is an operator with a step size 

5(:class:`moptipy.api.operators.Op1WithStepSize`) and the step size determines 

6how many elements of a permutation should be changed. 

7If you are working on permutations without repetitions, the operator in 

8:mod:`~moptipy.operators.permutations.op1_swap_try_n` is the better and faster 

9choice. On normal permutations, it will be equivalent to 

10:mod:`~moptipy.operators.permutations.op1_swap_exactly_n`, but faster. On 

11Permutations with repetitions, it will still be faster but less precisely 

12enforces the number of swaps. 

13 

14Let's say we have a "normal" permutation where each element occurs once. 

15The permutation has length `n`, let's say that its 

16:attr:`~moptipy.spaces.permutations.Permutations.blueprint` is 

17`[0, 1, 2, 3, 4, ..., n-1]`. Then, with one application of the operator, we 

18can change no less than two elements (`step_size=0.0`) and no more than `n` 

19(`step_size=1.0`). Clearly we cannot change only one element, because what 

20different value could we put there? We would violate the "permutation nature." 

21We can also not change more than `n` elements, obviously. 

22 

23What our operator does is then it computes `m` by using 

24:func:`~moptipy.operators.tools.exponential_step_size`. 

25This ensures that `m=2` for `step_size=0.0` and `m` is maximal for 

26`step_size=1.0`. In between the two, it extrapolates exponentially. This means 

27that small values of `step_size` will lead to few swap moves, regardless of 

28the length of the permutation and many swaps are performed only for 

29`step_size` values close to `1`. 

30Then it will pick `m` random indices and permute the elements at them such 

31that none remains at its current position. 

32That is rather easy to do. 

33Unfortunately, :class:`~moptipy.spaces.permutations.Permutations` allows also 

34permutations with repetitions. Here, things get dodgy. 

35 

36Let's say we have the blueprint `[0, 1, 1, 1, 1]`. Then we can only change 

37exactly two elements. 

38 

39>>> print(get_max_changes([0, 1, 1, 1, 1])) 

402 

41 

42If we have the blueprint `[0, 0, 1, 1, 1]`, then we can change two elements. 

43We can also change four elements. But there is no way to change three elements! 

44 

45>>> print(get_max_changes([0, 0, 1, 1, 1])) 

464 

47 

48So in the general case, we determine the maximum number `mx` of positions that 

49can be changed with one move via the function :func:`get_max_changes`. 

50We can still translate a `step_size` to a number `m` by doing 

51`m = 2 + int(round(step_size * (mx - 2)))`. 

52However, it is not clear whether all moves from `m=2` to `m=mx` are actually 

53possible. 

54 

55And even if they are possible, they might be very hard to sample randomly. 

56Some moves touching a large number `m` of positions may be restricted to 

57swap the elements at very specific indicies in a very specific order. Finding 

58such a move by chance may be rather unlikely. 

59 

60Indeed, a search operator should do random moves. I did not find a way to 

61construct moves according to a policy which is both random and can yield any 

62move. 

63So I divide the operator into two steps: 

64 

65First, the function :func:`find_move` tries to find a move for a given `m` 

66in a random fashion. This function tries to find the sequence of indices for a 

67cyclic swap where each element is different from both its successor and 

68predecessor. It builds such index sequences iteratively. This may fail: 

69as in the example above, for some values of `m` it might just not be 

70possible to construct a suitable sequence, either because there is none or 

71because building it randomly has too low of a chance of success. Hence, the 

72function tries to build at most `max_trials` sequences. Whenever building a 

73sequence, it also remembers the longest-so-far cyclic move. If that one is 

74shorter than `m` but all `max_trials` trials are exhausted, it returns this 

75sequence instead. So in summary, this function tries to find the longest 

76cyclic swap which is not longer than `m` and returns it. It may be shorter 

77than `m`. If we deal with permutations where each value occurs only once, this 

78function is guaranteed to find a sequence of length `m` in the first trial, 

79i.e., it does not waste runtime. 

80 

81Once a move was found, the function :func:`apply_move` applies it. Now, as 

82said, :func:`find_move` discovers cyclic changes that are reasonably random. 

83However, cyclic changes are not the only possible moves of length `m`. For 

84example, if we have the permutation blueprint `[0, 1, 2, 3, 4]`, a move of 

85length 4 could be to exchange `0` with `1` and to swap `2` with `3`. 

86:func:`find_move` cannot find this move, but it could find a cyclic swap of 

87`0` to `1`, `1` to `2`, `2` to `3`, and `3` to `1`. So it could find the 

88right indices for such a move, just restricted to the cyclic swapping. So 

89what :func:`apply_move` tries to do is to permute the indices discovered by 

90:func:`find_move` randomly and check whether this would still yield a feasible 

91move changing exactly `m` locations. Any shuffling of the elements at the 

92selected position which avoids putting the original values into the original 

93positions would do. Sometimes, most such shuffles work out, e.g., if we work 

94on the space of permutations where each element occurs once. Sometimes, the 

95only sequence that works is indeed the cyclic move and shuffling it cannot 

96work. So :func:`apply_move` again has a limit for the maximum number of 

97attempts to find a shuffle that works out. If it finds one, then it applies 

98it as move. If it does not find one and the trials are exhausted, it randomly 

99choses whether to apply the cyclic move as a cycle to the left or as a cycle 

100to the right. Either way, it will change exactly `m` positions of the 

101permutation, as prescribed by the move. Cycling one step in either direction 

102will always work, since each element is different from both its predecessor 

103and successor. (Cycling more than one step (but less than `m`) could 

104potentially fail in permutations with repetitions, because there is no 

105guarantee that any element is different from its successor's successor.) 

106 

107Now the overall operator just plugs these two functions together. It also adds 

108one slight improvement: If we demand to change a number `q` of locations for 

109the first time and :func:`find_move` fails to find a move of length `q` but 

110instead offers one of length `p<q`, then the operator remembers this. The next 

111time we ask to change `q` positions, it will directly try to change only `p`. 

112This memory is reset in :meth:`~Op1SwapExactlyN.initialize`. 

113 

114A similar but much more light-weight and faster operator is given in 

115:mod:`~moptipy.operators.permutations.op1_swap_try_n`. That operator also 

116tries to perform a given number of swaps, but puts in much less effort to 

117achieve this goal, i.e., it will only perform a single attempt. 

118""" 

119from typing import Counter, Final, Iterable 

120 

121import numba # type: ignore 

122import numpy as np 

123from numpy.random import Generator 

124from pycommons.types import check_int_range, type_error 

125 

126from moptipy.api.operators import Op1WithStepSize 

127from moptipy.operators.tools import exponential_step_size 

128from moptipy.spaces.permutations import Permutations 

129from moptipy.utils.logger import CSV_SEPARATOR, KeyValueLogSection 

130from moptipy.utils.nputils import DEFAULT_INT, fill_in_canonical_permutation 

131 

132 

133def get_max_changes(blueprint: Iterable[int]) -> int: 

134 """ 

135 Get the maximum number of changes possible for a given permutation. 

136 

137 :param blueprint: the blueprint permutation 

138 :returns: the maximum number of changes possible 

139 

140 >>> get_max_changes("1") 

141 0 

142 >>> get_max_changes("11") 

143 0 

144 >>> get_max_changes("12") 

145 2 

146 >>> get_max_changes("123") 

147 3 

148 >>> get_max_changes("1233") 

149 4 

150 >>> get_max_changes("12333") 

151 4 

152 >>> get_max_changes("1234") 

153 4 

154 >>> get_max_changes("12344") 

155 5 

156 >>> get_max_changes("123444") 

157 6 

158 >>> get_max_changes("1234444") 

159 6 

160 >>> get_max_changes("12334444") 

161 8 

162 >>> get_max_changes("123344445") 

163 9 

164 >>> get_max_changes("1233444455") 

165 10 

166 >>> get_max_changes("12334444555") 

167 11 

168 >>> get_max_changes("123344445555") 

169 12 

170 >>> get_max_changes("1233444455555") 

171 13 

172 >>> get_max_changes("12334444555555") 

173 14 

174 >>> get_max_changes("123344445555555") 

175 15 

176 >>> get_max_changes("1233444455555555") 

177 16 

178 >>> get_max_changes("12334444555555555") 

179 16 

180 >>> get_max_changes("112233") 

181 6 

182 >>> get_max_changes("11223344") 

183 8 

184 >>> get_max_changes("112233445") 

185 9 

186 >>> get_max_changes("1122334455") 

187 10 

188 >>> get_max_changes("11223344555") 

189 11 

190 >>> get_max_changes("112233445555555") 

191 15 

192 >>> get_max_changes("1122334455555555") 

193 16 

194 >>> get_max_changes("11223344555555555") 

195 16 

196 """ 

197 # Create tuples of (count, negative priority, value). 

198 counts: Final[list[list[int]]] = [ 

199 [a[1], 0, a[0]] for a in Counter[int](blueprint).most_common()] 

200 if len(counts) <= 1: 

201 return 0 

202 

203# We simulate a chain swap. We begin with the element that appears the least 

204# often. We take this element and reduce its count. Next, we take the element 

205# that appears the most often. We reduce its count. And so on. Ties are broken 

206# by prioritizing the element that was not used the longest. If the element 

207# that we want to pick is the same as the previously picked one, we skip over 

208# it. If no element can be picked, we quit. 

209 changes: int = 0 

210 last: int | None = None 

211 found: bool = True 

212 smallest: bool = True 

213 while found: 

214 found = False 

215# We sort alternatingly sometimes we pick the smallest, sometimes the largest. 

216 if smallest: 

217 counts.sort() 

218 else: 

219 counts.sort(key=lambda a: [-a[0], a[1]]) 

220 smallest = not smallest # realize the alternating sorting 

221# Now we iterate over the sorted list and pick the first suitable element. 

222# I know: There will be at most two iterations of this loop ... but whatever. 

223 for idx, chosen in enumerate(counts): 

224 current = chosen[2] 

225 if current == last: # We cannot pick the same element twice 

226 continue # in a row. So we skip over it. 

227 cnt = chosen[0] 

228 if cnt <= 1: # How often does this element exist in the list? 

229 del counts[idx] # noqa 

230 else: 

231 chosen[0] = cnt - 1 # noqa 

232 changes += 1 # Yeah, we can increase the changes. 

233 chosen[1] = changes # Increase negative priority. 

234 found = True # We found an element. 

235 last = current # Remember the element. 

236 break # Quit (after at most two loop iterations). 

237 if changes <= 1: # This cannot be. 

238 raise ValueError( 

239 f"Error in counting possible changes for {blueprint}.") 

240 return changes 

241 

242 

243@numba.njit(cache=True, inline="always", fastmath=True, boundscheck=False) 

244def find_move(x: np.ndarray, indices: np.ndarray, step_size: int, 

245 random: Generator, max_trials: int, 

246 temp: np.ndarray) -> np.ndarray: 

247 """ 

248 Find a move of at most step_size changes and store it in indices. 

249 

250 For any pure permutation `x`, we can basically find any move between 

251 `step_size=2` and `len(x)`. If we have permutations with repetitions, the 

252 upper limit becomes smaller, because it may not be possible to change all 

253 elements at once. For example, in `[0, 0, 1, 1, 1, 1, 1]`, we can change 

254 at most four elements. For this purpose, :func:`get_max_changes` exists. 

255 It tells us the upper limit of changes, which would be four here: 

256 

257 >>> print(get_max_changes([0, 0, 1, 1, 1, 1, 1])) 

258 4 

259 

260 Yet, this does not necessarily mean that we can make all moves including 

261 2, 3, and 4 changes. If you look at the above example, you may find that 

262 it is impossible to change exactly 3 locations. We can only actually do 

263 step sizes of 2 and 4, but neither 3 nor 5, 6, nor 7. 

264 In reality, even if it was possible to make a big move, it might be very 

265 unlikely to be able to this in a *random* fashion. If you look at the 

266 code of :func:`get_max_changes`, it tries to find the longest possible 

267 cyclic move by working through the elements of the permutation in a very 

268 specific order. Using a different (or random) order would yield shorter 

269 moves. 

270 

271 Our method :func:`find_move` here therefore tries to find the longest 

272 possible cyclic swap involving at most `step_size` indices. It may find 

273 such a move that uses exactly `step_size` indices. But it may also find 

274 a shorter move because either the perfect complete feasible move touching 

275 `step_size` indices does not exist or because finding it in a random 

276 fashion may take too long. (For this purpose, `max_trials` exists to 

277 limit the search for the right move.) 

278 

279 For normal permutations, it will find the move of exactly the right 

280 length. For permutations with repetitions, it has a decent chance to find 

281 a move of the length `step_size` if a) such a move exists and 

282 b) `step_size` is not too big. Otherwise, it should find a shorter but 

283 still reasonably large move. 

284 

285 :param x: the permutation to be modified in the end 

286 :param indices: the set of indices, which must be the canonical 

287 permutation of `0...len(x)-1` 

288 :param step_size: the step size, i.e., the number of elements to change 

289 :param random: the random number generator 

290 :param max_trials: the maximum number of trials 

291 :param temp: a temporary array 

292 :returns: the discovered move 

293 

294 >>> import numpy as npx 

295 >>> from numpy.random import default_rng 

296 >>> gen = default_rng(12) 

297 >>> perm = npx.array([0, 1, 2, 3, 3, 3, 3, 3, 3], int) 

298 >>> use_indices = npx.array(range(len(perm)), int) 

299 >>> want_size = len(perm) 

300 >>> print(want_size) 

301 9 

302 >>> print(get_max_changes(perm)) 

303 6 

304 >>> use_temp = npx.empty(len(perm), int) 

305 >>> res = find_move(perm, use_indices, want_size, gen, 1000, use_temp) 

306 >>> print(res) 

307 [5 2 8 1 4 0] 

308 >>> perm2 = perm.copy() 

309 >>> perm2[res] = perm2[npx.roll(res, 1)] 

310 >>> print(f"{perm} vs. {perm2}") 

311 [0 1 2 3 3 3 3 3 3] vs. [3 3 3 3 1 0 3 3 2] 

312 >>> print(sum(perm != perm2)) 

313 6 

314 >>> perm = npx.array([1, 3, 5, 9, 4, 2, 11, 7], int) 

315 >>> want_size = len(perm) 

316 >>> print(want_size) 

317 8 

318 >>> use_indices = npx.array(range(len(perm)), int) 

319 >>> use_temp = npx.empty(len(perm), int) 

320 >>> res = find_move(perm, use_indices, want_size, gen, 1, use_temp) 

321 >>> print(res) 

322 [4 0 5 3 1 7 2 6] 

323 >>> print(len(res)) 

324 8 

325 >>> perm2 = perm.copy() 

326 >>> perm2[res] = perm2[npx.roll(res, 1)] 

327 >>> print(f"{perm} vs. {perm2}") 

328 [ 1 3 5 9 4 2 11 7] vs. [ 4 9 7 2 11 1 5 3] 

329 >>> print(sum(perm != perm2)) 

330 8 

331 """ 

332 length: Final[int] = len(x) 

333 lm1: Final[int] = length - 1 

334 sm1: int = step_size - 1 

335 best_size: int = -1 

336 

337# We spent at most `max_trials` trials to find a perfect move. The reason is 

338# that some values of `step_size` might be impossible to achieve, other may 

339# be achievable only with a very low probability. So we need a trial limit to 

340# avoid entering a very long or even endless loop. 

341 for _ in range(max_trials): 

342 current_best_size: int = -1 # longest feasible move this round 

343# We aim to store a suitable selection of indices at indices[0:step_size]. 

344# Like in :mod:`moptipy.operators.permutations.op1_swapn`, we try to create a 

345# feasible move as a series of cyclic swaps. Basically, the element at index 

346# i1 would be moved to index i2, the element at i2 to i3, ..., the element at 

347# the last index ix to i1. We make sure that all indices are used at most 

348# once. 

349# We proceed similar to a partial Fisher-Yates shuffle. 

350# The variable "found" is the number of indices that we have fixed so far. 

351# For each new position, we test the remaining indices in random order. 

352# An index can only be used if it points to an element different to what the 

353# last index points to, or else the cyclic swap would be meaningless. 

354# For the last index to be chosen, that element must also be different from 

355# the first element picked. 

356# So a potential index may not work. We test each index at most once by moving 

357# the tested indices into the range indices[found:tested] and keep increasing 

358# tested and reset it once we found a new acceptable index. 

359# This procedure, however, may end up in a dead end. We therefore need to wrap 

360# it into a loop that tries until success... ...but we cannot do that because 

361# success may not be possible. Some moves may be impossible to do. 

362 i_idx = random.integers(0, length) # Get the first random index. 

363 i_src = indices[i_idx] # Pick the actual index at that index. 

364 indices[0], indices[i_idx] = i_src, indices[0] # Swap it to 0. 

365 found: int = 1 # the number of found indices 

366 tested: int = 1 # the number of tested indices 

367 last = first = x[i_src] # Get the value at the first index. 

368 continue_after: bool = found < sm1 # continue until step_size-1 

369 while True: 

370 i_idx = random.integers(tested, length) # Get random index. 

371 i_dst = indices[i_idx] # Pick the actual index. 

372 current = x[i_dst] # Get value at the new index. 

373 can_end: bool = current != first 

374 accept: bool = (current != last) and (continue_after or can_end) 

375 if accept: 

376 # Now we move the new index into the "found" section. 

377 indices[found], indices[i_idx] = i_dst, indices[found] 

378 tested = found = found + 1 # found++, reset tested. 

379 last = current # Store value from i_dst in last. 

380 if can_end: 

381 current_best_size = found 

382 if not continue_after: 

383 return indices[0:found] # We won! 

384 continue_after = found < sm1 # continue until step_size-1 

385 continue 

386 if tested >= lm1: # Are we in a dead end? 

387 break # and try again freshly. 

388 indices[tested], indices[i_idx] = i_dst, indices[tested] 

389 tested += 1 # We have tested another value. 

390 

391# If we get here, we did not succeed in creating a complete move this round. 

392# However, current_best_size should indicate one possible shorter move. 

393# It must be at least >= 2, because we will definitely be able to find two 

394# different elements in x. We will remember the longest feasible move 

395# discovered in all max_trials iterations. 

396 if current_best_size > best_size: 

397 best_size = current_best_size 

398 temp[0:best_size] = indices[0:best_size] 

399 

400# If get here, we have completed the main loop. We could not find a complete 

401# feasible move that changes exactly step_size locations. However, we should 

402# have a shorter move by now stored in temp. So we copy its indices. 

403 return temp[0:best_size] 

404 

405 

406@numba.njit(cache=True, inline="always", fastmath=True, boundscheck=False) 

407def apply_move(x: np.ndarray, dest: np.ndarray, move: np.ndarray, 

408 random: Generator, max_trials: int) -> None: 

409 """ 

410 Apply a given move copying from source `x` to `dest`. 

411 

412 `move` must be the return value of :func:`find_move`. 

413 

414 :param x: the source permutation 

415 :param dest: the destination permutation 

416 :param move: the move of length `step_size` 

417 :param random: the random number generator 

418 :param max_trials: a maximum number of trials 

419 

420 >>> import numpy as npx 

421 >>> from numpy.random import default_rng 

422 >>> rand = default_rng(12) 

423 >>> xx = npx.array([0, 1, 2, 3, 3, 3, 3, 3, 3], int) 

424 >>> dd = npx.empty(len(xx), int) 

425 >>> mv = npx.array([5, 2, 8, 1, 4, 0], int) 

426 >>> print(len(mv)) 

427 6 

428 >>> apply_move(xx, dd, mv, rand, 0) 

429 >>> print(dd) 

430 [3 3 3 3 0 2 3 3 1] 

431 >>> print(sum(dd != xx)) 

432 6 

433 >>> apply_move(xx, dd, mv, rand, 1000) 

434 >>> print(dd) 

435 [3 3 3 3 2 1 3 3 0] 

436 >>> print(sum(dd != xx)) 

437 6 

438 >>> xx = npx.array([1, 3, 5, 9, 4, 2, 11, 7], int) 

439 >>> dd = npx.empty(len(xx), int) 

440 >>> mv = npx.array([4, 0, 5, 3, 1, 7, 2, 6], int) 

441 >>> print(len(mv)) 

442 8 

443 >>> apply_move(xx, dd, mv, rand, 0) 

444 >>> print(dd) 

445 [ 4 9 7 2 11 1 5 3] 

446 >>> print(sum(dd != xx)) 

447 8 

448 >>> xx = npx.array([1, 3, 5, 9, 4, 2, 11, 7], int) 

449 >>> apply_move(xx, dd, mv, rand, 10) 

450 >>> print(dd) 

451 [ 5 4 2 11 7 3 9 1] 

452 >>> print(sum(dd != xx)) 

453 8 

454 """ 

455 dest[:] = x[:] # First, we copy `x` to `dest`. 

456 step_size: Final[int] = len(move) # Get the move size. 

457# We now try to replace the sub-string x[move] with a random permutation of 

458# itself. Since `move` is the return value of `find_move`, we know that it 

459# could be realized as a cyclic swap. However, we are not yet sure whether it 

460# can also be some other form of move, maybe swapping a few pairs of elements. 

461# We try to find this out in this loop by testing `max_trials` random 

462# permutations of the original subsequence. 

463 orig: np.ndarray = dest[move] # Get the original subsequence. 

464 perm: np.ndarray = orig.copy() 

465 for _ in range(max_trials): # Try to permute it. 

466 random.shuffle(perm) # Shuffle the permutation 

467 if sum(perm != orig) == step_size: # If all elements are now... 

468 dest[move] = perm # ...at different places, accept the move and 

469 return # quit 

470# If we get here, trying random permutations of the sequence did not work. 

471# We now simply perform a cyclic swap. A cyclic swap has the property that 

472# each element is different from both its predecessor and its successor. 

473# Hence, we could cycle left or cycle right. Now one could argue that this 

474# should not matter, since we have constructed the move in a random way via 

475# find_move. However, I am not sure. If we have a permutation with repetition, 

476# then finding certain move patterns could be more likely than others. For 

477# example, if one element occurs much more often than another one, one of its 

478# instance could be more likely chosen as first element of the move. A more 

479# rarely occurring element could then appear later in the move when there are 

480# not many other choices left. Hence, it might be that the moves found by 

481# find_move are not uniformly distributed over the realm of possible moves of 

482# a given step length. In this case, it may be useful to randomly choose 

483# whether to cycle left or right. We cannot cycle more than one step, though, 

484# because there is guarantee that an element is different from its successor's 

485# successor. 

486 dest[move] = np.roll(orig, 1 if random.integers(0, 2) == 0 else -1) 

487 

488 

489class Op1SwapExactlyN(Op1WithStepSize): 

490 """A unary search operator that swaps `n` (different) elements.""" 

491 

492 def __init__(self, perm: Permutations, 

493 max_move_trials: int = 1000, 

494 max_apply_trials: int = 1000) -> None: 

495 """ 

496 Initialize the operator. 

497 

498 :param perm: the base permutation 

499 :param max_move_trials: the maximum number of attempts to generate a 

500 fitting move 

501 :param max_apply_trials: the maximum number of attempts to apply a 

502 move via a random permutation 

503 """ 

504 super().__init__() 

505 if not isinstance(perm, Permutations): 

506 raise type_error(perm, "perm", Permutations) 

507 

508# If each value appears exactly once in the permutation, then we can 

509# easily do perm.dimension changes. If the permutation is with 

510# repetitions, then it might be fewer, so we need to check. 

511 is_pure_perm: Final[bool] = perm.n() == perm.dimension 

512 #: the maximum number of possible changes 

513 self.max_changes: Final[int] = check_int_range( 

514 perm.dimension if is_pure_perm 

515 else get_max_changes(perm.blueprint), 

516 "max_changes", 2, 100_000_000) 

517 #: the maximum number of attempts to find a move with the exact step 

518 #: size 

519 self.max_move_trials: Final[int] =\ 

520 check_int_range(max_move_trials, "max_move_trials", 1) 

521 #: the maximum number of attempts to apply a random permutation of the 

522 #: move before giving up and applying it as cyclic swap 

523 self.max_apply_trials: Final[int] =\ 

524 check_int_range(max_apply_trials, "max_apply_trials", 1) 

525 #: the set of chosen indices 

526 self.__indices: Final[np.ndarray] = np.empty( 

527 perm.dimension, DEFAULT_INT) 

528 #: a temporary array 

529 self.__temp: Final[np.ndarray] = np.empty( 

530 perm.dimension, DEFAULT_INT) 

531 #: the initial move map that maps step sizes to feasible sizes 

532 self.__initial_move_map: Final[dict[int, int]] = {i: i for i in range( 

533 2, perm.dimension + 1)} if is_pure_perm else {2: 2} 

534 #: the move map that maps step sizes to feasible sizes 

535 self.__move_map: Final[dict[int, int]] = {} 

536 

537 def initialize(self) -> None: 

538 """Initialize this operator.""" 

539 super().initialize() 

540 fill_in_canonical_permutation(self.__indices) 

541 self.__move_map.clear() 

542 self.__move_map.update(self.__initial_move_map) 

543 

544 def op1(self, random: Generator, dest: np.ndarray, x: np.ndarray, 

545 step_size: float = 0.0) -> None: 

546 """ 

547 Copy `x` into `dest` and then swap several different values. 

548 

549 :param random: the random number generator 

550 :param dest: the array to receive the modified copy of `x` 

551 :param x: the existing point in the search space 

552 :param step_size: the number of elements to swap 

553 """ 

554# compute the real step size based on the maximum changes 

555 use_step_size: int = exponential_step_size( 

556 step_size, 2, self.max_changes) 

557# look up in move map: Do we already know whether this step size works or can 

558# it be replaced with a similar smaller one? 

559 move_map: Final[dict[int, int]] = self.__move_map 

560 mapped_step_size = move_map.get(use_step_size, -1) 

561 if mapped_step_size >= 2: 

562 use_step_size = mapped_step_size 

563 

564 move: Final[np.ndarray] = find_move( 

565 x, self.__indices, use_step_size, random, self.max_move_trials, 

566 self.__temp) 

567 

568# If we did not yet know the move size, then update it. 

569 if mapped_step_size == -1: 

570 self.__move_map[use_step_size] = len(move) 

571 self.__move_map[use_step_size] = use_step_size 

572# Finally, apply the move. 

573 apply_move(x, dest, move, random, self.max_move_trials) 

574 

575 def __str__(self) -> str: 

576 """ 

577 Get the name of this unary operator. 

578 

579 :returns: "swapxn", the name of this operator 

580 :retval "swapxn": always 

581 """ 

582 return "swapxn" 

583 

584 def log_parameters_to(self, logger: KeyValueLogSection) -> None: 

585 """ 

586 Log the parameters of this operator to the given logger. 

587 

588 :param logger: the logger for the parameters 

589 """ 

590 super().log_parameters_to(logger) 

591 logger.key_value("maxChanges", self.max_changes) 

592 logger.key_value("maxMoveTrials", self.max_move_trials) 

593 logger.key_value("maxApplyTrials", self.max_apply_trials) 

594 

595# Translate the move map to some strings like "2;5-8;12", meaning that all the 

596# moves 2, 5, 6, 7, 8, and 12 were performed and valid. 

597 kvs: list[int] = [k for k, v in self.__move_map.items() if k == v] 

598 kvs.sort() 

599 lk: Final[int] = len(kvs) - 1 

600 kvs = [(e if ((i <= 0) or (i >= lk) or kvs[i - 1] != (e - 1)) 

601 or (kvs[i + 1] != (e + 1)) else -1) 

602 for i, e in enumerate(kvs)] 

603 kvs = [e for i, e in enumerate(kvs) if 

604 (i <= 0) or (i >= lk) or (e != -1) or (kvs[i - 1] != -1)] 

605 logger.key_value("moves", CSV_SEPARATOR.join( 

606 "-" if k < 0 else str(k) for k in kvs).replace( 

607 f"{CSV_SEPARATOR}-{CSV_SEPARATOR}", "-"))