pt-neural-memory 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,72 @@
1
+ """Fast associative memory built from addressed recursive-least-squares cells.
2
+
3
+ The package has three layers:
4
+
5
+ * :class:`RLSMemCell` -- one linear map fitted by the exact recursive-least-
6
+ squares update, which is the Kalman filter for ``value = W key + noise``,
7
+ with its posterior error covariance.
8
+ * :class:`AddressedMemory` -- a bank of independent Kalman filters selected by
9
+ the sign bits of a random projection, so many local maps can be stored and
10
+ read.
11
+ * :class:`AddressedRegressor` -- a random feature map composed with the table,
12
+ which turns the memory into a nonlinear regressor fitted in closed form.
13
+
14
+ A store is an indexed, exact posterior update and a read is a table lookup and
15
+ a matrix-vector product. Stores are no-grad by default;
16
+ ``store(..., grad_enabled=True)`` keeps the graph through a write. The feature
17
+ map remains random and untrained, and address selection is discrete.
18
+ """
19
+
20
+ from importlib.metadata import PackageNotFoundError, version
21
+
22
+ from pt_neural_memory.addressed_memory import (
23
+ AddressedMemory,
24
+ CellRead,
25
+ VerifiedMemory,
26
+ )
27
+ from pt_neural_memory.addressed_regressor import AddressedRegressor, RandomFeatureMap
28
+ from pt_neural_memory.forgetting_data import Task, regression_stream, target_function
29
+ from pt_neural_memory.mem_rls import RLSCellBank, RLSMemCell
30
+ from pt_neural_memory.memory_sizing import (
31
+ ADMISSIBLE_WRITERS_2PCT,
32
+ ADMISSIBLE_WRITERS_5PCT,
33
+ MemorySizing,
34
+ balanced_layout,
35
+ bits_for_capacity,
36
+ capacity,
37
+ pairs_per_cell,
38
+ size_for_budget,
39
+ state_bytes,
40
+ state_parts,
41
+ writers_per_cell,
42
+ )
43
+
44
+ try:
45
+ __version__ = version("pt-neural-memory")
46
+ except PackageNotFoundError: # running from a source tree without an install
47
+ __version__ = "0.1.0"
48
+
49
+ __all__ = [
50
+ "ADMISSIBLE_WRITERS_2PCT",
51
+ "ADMISSIBLE_WRITERS_5PCT",
52
+ "AddressedMemory",
53
+ "AddressedRegressor",
54
+ "CellRead",
55
+ "MemorySizing",
56
+ "RLSCellBank",
57
+ "RLSMemCell",
58
+ "RandomFeatureMap",
59
+ "Task",
60
+ "VerifiedMemory",
61
+ "__version__",
62
+ "balanced_layout",
63
+ "bits_for_capacity",
64
+ "capacity",
65
+ "pairs_per_cell",
66
+ "regression_stream",
67
+ "size_for_budget",
68
+ "state_bytes",
69
+ "state_parts",
70
+ "target_function",
71
+ "writers_per_cell",
72
+ ]
@@ -0,0 +1,474 @@
1
+ """Addressed memory: a table of RLS cells selected by projection bits.
2
+
3
+ Each of the ``n_blocks`` blocks owns an independent random projection of the
4
+ key. A key's address in a block is the sign pattern of its projected
5
+ coordinates read as an ``n_bits``-bit integer, so the block has ``2**n_bits``
6
+ cells. A cell is a recursive-least-squares memory cell: it fits the local
7
+ linear map ``value = weights @ key`` from the pairs written to it, and carries
8
+ the exact ridge posterior that ``RLSMemCell`` maintains.
9
+
10
+ ``store`` writes each pair not only at the key's address but at every address
11
+ within ``write_radius`` bit flips of it. Radius 1 therefore trains
12
+ ``n_blocks * (n_bits + 1)`` cells per pair instead of ``n_blocks``, and lets a
13
+ query whose address was corrupted in one bit still find a cell that was trained
14
+ on it. Because a cell is a regression rather than a mean, the copies are not
15
+ wasted redundancy even on clean keys: each neighbour cell learns its own local
16
+ map from every pair that touches it.
17
+
18
+ ``forward`` reads the cell at the query's own address in each block and
19
+ averages the blocks. ``VerifiedMemory`` instead probes the addresses within
20
+ ``read_radius`` flips and reads the cell that explains the query best.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass
26
+ from itertools import combinations, product
27
+
28
+ import torch
29
+ from torch import nn
30
+
31
+ from pt_neural_memory.mem_rls import RLSCellBank
32
+
33
+
34
+ def _flip_masks(n_bits: int, radius: int) -> list[tuple[int, ...]]:
35
+ """Every set of bit positions of size at most ``radius``.
36
+
37
+ Each tuple names one address in the write neighbourhood: the key's own
38
+ address for the empty tuple, otherwise the address reached by flipping
39
+ those bits.
40
+ """
41
+ return [
42
+ positions
43
+ for size in range(radius + 1)
44
+ for positions in combinations(range(n_bits), size)
45
+ ]
46
+
47
+
48
+ def _mask_value(mask: tuple[int, ...]) -> int:
49
+ """Pack bit positions into an integer XOR mask."""
50
+ value = 0
51
+ for position in mask:
52
+ value |= 1 << position
53
+ return value
54
+
55
+
56
+ class AddressedMemory(nn.Module):
57
+ """``n_blocks`` blocks of ``2**n_bits`` RLS cells, addressed by projection signs.
58
+
59
+ The block projections have orthonormal rows, so for a key with i.i.d.
60
+ standard-normal coordinates each bit is an independent fair coin; the
61
+ address of a random key is uniform on ``2**n_bits`` cells and two random
62
+ keys share a block address with probability ``2**-n_bits``.
63
+
64
+ A cell holds the ridge posterior of every pair written to it: its map is
65
+ the exact least-squares fit ``value = weights @ key`` regularised by
66
+ ``rr_lambda``, and its covariance says how well-constrained that map is.
67
+ One cell can therefore recall up to ``key_dim`` independent associations
68
+ exactly, where a cell that stores a mean can recall only one. An unwritten
69
+ cell is the prior and answers zero. Writes are no-grad by default; pass
70
+ ``grad_enabled=True`` to ``store`` to keep the graph through the written
71
+ keys and values. Address selection is discrete, so gradients do not flow
72
+ through the choice of cell.
73
+
74
+ Args:
75
+ key_dim: dimension of the keys the memory is addressed with.
76
+ value_dim: dimension of the values stored in a cell.
77
+ n_blocks: number of independent blocks.
78
+ n_bits: address width per block; the block has ``2**n_bits`` cells.
79
+ Capped at 20 so a typo cannot allocate a billion cells, and it must
80
+ not exceed ``key_dim``: the address bits are independent only while
81
+ the projection rows are orthonormal, and a ``key_dim``-dimensional
82
+ space admits at most ``key_dim`` of them.
83
+ write_radius: Hamming radius of the write neighbourhood. ``0`` writes
84
+ only the key's own address; ``1`` also writes every one-bit
85
+ neighbour.
86
+ rr_lambda: ridge prior of every cell; see ``RLSMemCell``.
87
+ seed: seed of the projection generator, not of the data.
88
+ dtype: state dtype, promoted to at least float32.
89
+ """
90
+
91
+ projection: torch.Tensor
92
+ cells: RLSCellBank
93
+
94
+ def __init__(
95
+ self,
96
+ key_dim: int,
97
+ value_dim: int,
98
+ n_blocks: int = 3,
99
+ n_bits: int = 12,
100
+ write_radius: int = 1,
101
+ rr_lambda: float = 1.0,
102
+ seed: int = 0,
103
+ dtype: torch.dtype | None = None,
104
+ ) -> None:
105
+ super().__init__()
106
+ if key_dim < 1 or value_dim < 1:
107
+ raise ValueError(
108
+ f"key_dim and value_dim must be positive, got {key_dim} and {value_dim}"
109
+ )
110
+ if n_blocks < 1:
111
+ raise ValueError(f"n_blocks must be positive, got {n_blocks}")
112
+ if not 1 <= n_bits <= 20:
113
+ raise ValueError(f"n_bits must lie in [1, 20], got {n_bits}")
114
+ if n_bits > key_dim:
115
+ raise ValueError(
116
+ f"n_bits must not exceed key_dim, got n_bits={n_bits} and "
117
+ f"key_dim={key_dim}: the address bits are independent only "
118
+ f"while the projection rows are orthonormal, and {key_dim} "
119
+ f"dimensions admit at most {key_dim} of them"
120
+ )
121
+ if not 0 <= write_radius <= n_bits:
122
+ raise ValueError(
123
+ f"write_radius must lie in [0, {n_bits}], got {write_radius}"
124
+ )
125
+ self.key_dim = key_dim
126
+ self.value_dim = value_dim
127
+ self.n_blocks = n_blocks
128
+ self.n_bits = n_bits
129
+ self.n_cells = 1 << n_bits
130
+ self.write_radius = write_radius
131
+ state_dtype = torch.promote_types(dtype or torch.float32, torch.float32)
132
+ self.register_buffer("projection", self._make_projection(seed, state_dtype))
133
+ self.cells = RLSCellBank(
134
+ key_dim,
135
+ value_dim,
136
+ n_blocks * self.n_cells,
137
+ rr_lambda=rr_lambda,
138
+ dtype=dtype,
139
+ )
140
+
141
+ def _make_projection(self, seed: int, dtype: torch.dtype) -> torch.Tensor:
142
+ """One ``n_bits x key_dim`` block projection with orthonormal rows."""
143
+ generator = torch.Generator().manual_seed(seed)
144
+ blocks = []
145
+ for _ in range(self.n_blocks):
146
+ raw = torch.randn(
147
+ self.key_dim, self.n_bits, generator=generator, dtype=torch.float64
148
+ )
149
+ orthogonal, _ = torch.linalg.qr(raw)
150
+ blocks.append(orthogonal[:, : self.n_bits].T.contiguous())
151
+ return torch.stack(blocks).to(dtype)
152
+
153
+ def projections(self, key: torch.Tensor) -> torch.Tensor:
154
+ """Signed projection coordinates, shape ``(batch, n_blocks, n_bits)``.
155
+
156
+ A row has unit norm, so for a key with standard-normal coordinates the
157
+ coordinate is standard normal and its absolute value is the address
158
+ margin: how much key noise that bit tolerates before it flips.
159
+ """
160
+ self._check_key(key)
161
+ return torch.einsum("bd,nmd->bnm", key, self.projection)
162
+
163
+ def bit_patterns(self, key: torch.Tensor) -> torch.Tensor:
164
+ """Address bits, shape ``(batch, n_blocks, n_bits)``; ``True`` is 1."""
165
+ return self.projections(key) >= 0
166
+
167
+ def addresses(self, key: torch.Tensor) -> torch.Tensor:
168
+ """Packed addresses, shape ``(batch, n_blocks)``, dtype int64."""
169
+ bits = self.bit_patterns(key).to(torch.int64)
170
+ powers = 1 << torch.arange(self.n_bits, device=key.device)
171
+ return (bits * powers).sum(dim=-1)
172
+
173
+ def _check_key(self, key: torch.Tensor) -> None:
174
+ if key.dim() != 2 or key.shape[1] != self.key_dim:
175
+ raise ValueError(
176
+ f"key must have shape (batch, {self.key_dim}), got {tuple(key.shape)}"
177
+ )
178
+
179
+ def _flat_indices(self, address: torch.Tensor) -> torch.Tensor:
180
+ """Flatten ``(block, cell)`` pairs into one index per block row."""
181
+ blocks = torch.arange(self.n_blocks, device=address.device)
182
+ return address + blocks * self.n_cells
183
+
184
+ def _write_addresses(
185
+ self, key: torch.Tensor
186
+ ) -> list[tuple[torch.Tensor, torch.Tensor | None]]:
187
+ """Addresses to write, with an optional per-entry validity mask.
188
+
189
+ ``None`` means every row is written; a boolean mask of shape
190
+ ``(batch, n_blocks)`` selects the entries of an address tensor that
191
+ are written. The mask is what lets a subclass skip neighbours.
192
+ """
193
+ base = self.addresses(key)
194
+ return [
195
+ (base ^ _mask_value(mask), None)
196
+ for mask in _flip_masks(self.n_bits, self.write_radius)
197
+ ]
198
+
199
+ def store(
200
+ self,
201
+ key: torch.Tensor,
202
+ value: torch.Tensor,
203
+ grad_enabled: bool = False,
204
+ ) -> None:
205
+ """Train the write neighbourhood of a batch of associations.
206
+
207
+ Each pair trains the cell at its address and every cell within
208
+ ``write_radius`` flips of it, in every block. The update is no-grad by
209
+ default; pass ``grad_enabled=True`` to keep the autograd graph through
210
+ the key and value.
211
+
212
+ Args:
213
+ key: ``(batch, key_dim)`` keys to address.
214
+ value: ``(batch, value_dim)`` values to associate with them.
215
+ grad_enabled: keep the autograd graph for the update.
216
+ """
217
+ if key.dim() != 2 or value.dim() != 2:
218
+ raise ValueError(
219
+ "key and value must be 2D (batch, features), got "
220
+ f"{tuple(key.shape)} and {tuple(value.shape)}"
221
+ )
222
+ if key.shape[0] != value.shape[0]:
223
+ raise ValueError(
224
+ f"batch sizes differ: {key.shape[0]} keys, {value.shape[0]} values"
225
+ )
226
+ if key.shape[1] != self.key_dim:
227
+ raise ValueError(
228
+ f"key must have shape (batch, {self.key_dim}), got {tuple(key.shape)}"
229
+ )
230
+ if value.shape[1] != self.value_dim:
231
+ raise ValueError(
232
+ f"value must have shape (batch, {self.value_dim}), "
233
+ f"got {tuple(value.shape)}"
234
+ )
235
+ if key.shape[0] == 0:
236
+ return
237
+ with torch.set_grad_enabled(mode=grad_enabled):
238
+ expanded_key = (
239
+ key[:, None, :]
240
+ .expand(-1, self.n_blocks, -1)
241
+ .reshape(-1, self.key_dim)
242
+ )
243
+ expanded_value = (
244
+ value[:, None, :]
245
+ .expand(-1, self.n_blocks, -1)
246
+ .reshape(-1, self.value_dim)
247
+ )
248
+ # One bank call for the whole neighbourhood: the masks name disjoint
249
+ # cells for a given pair, and the bank sequences any repeats itself.
250
+ indices = []
251
+ keys = []
252
+ values = []
253
+ for address, valid in self._write_addresses(key):
254
+ flat = self._flat_indices(address).reshape(-1)
255
+ if valid is None:
256
+ indices.append(flat)
257
+ keys.append(expanded_key)
258
+ values.append(expanded_value)
259
+ else:
260
+ keep = valid.reshape(-1)
261
+ indices.append(flat[keep])
262
+ keys.append(expanded_key[keep])
263
+ values.append(expanded_value[keep])
264
+ self.cells.store(
265
+ torch.cat(indices),
266
+ torch.cat(keys),
267
+ torch.cat(values),
268
+ grad_enabled=grad_enabled,
269
+ )
270
+
271
+ def read_blocks(self, key: torch.Tensor) -> torch.Tensor:
272
+ """Per-block cell predictions, shape ``(n_blocks, batch, value_dim)``."""
273
+ self._check_key(key)
274
+ flat = self._flat_indices(self.addresses(key))
275
+ keys = key[:, None, :].expand(-1, self.n_blocks, -1)
276
+ return self.cells.predict(flat, keys).transpose(0, 1)
277
+
278
+ def read_variances(self, key: torch.Tensor) -> torch.Tensor:
279
+ """Per-block posterior variance of the query, shape ``(n_blocks, batch)``.
280
+
281
+ The cell's own confidence signal: ``‖k‖² / λ`` at an unwritten cell,
282
+ about one at a cell whose map was fitted to this key.
283
+ """
284
+ self._check_key(key)
285
+ flat = self._flat_indices(self.addresses(key))
286
+ keys = key[:, None, :].expand(-1, self.n_blocks, -1)
287
+ return self.cells.variance(flat, keys).transpose(0, 1)
288
+
289
+ def forward(self, key: torch.Tensor) -> torch.Tensor:
290
+ """Read the cell at the query address, averaged over blocks."""
291
+ return self.read_blocks(key).mean(dim=0)
292
+
293
+
294
+ @dataclass
295
+ class CellRead:
296
+ """The cell selected in each block, and the evidence for the choice."""
297
+
298
+ values: torch.Tensor
299
+ """Selected cell prediction per block, shape ``(n_blocks, batch, value_dim)``."""
300
+ scores: torch.Tensor
301
+ """Posterior variance of the query under the selected cell; lower is better."""
302
+ indices: torch.Tensor
303
+ """Flat ids of the selected cells, shape ``(n_blocks, batch)``, int64."""
304
+
305
+ def best(self) -> torch.Tensor:
306
+ """Value of the best-scoring block, shape ``(batch, value_dim)``."""
307
+ pick = self.scores.argmin(dim=0)
308
+ rows = torch.arange(pick.shape[0], device=pick.device)
309
+ return self.values[pick, rows]
310
+
311
+ def mean(self) -> torch.Tensor:
312
+ """Mean of the selected block values, shape ``(batch, value_dim)``."""
313
+ return self.values.mean(dim=0)
314
+
315
+
316
+ class VerifiedMemory(AddressedMemory):
317
+ """Addressed memory that probes neighbouring addresses before reading.
318
+
319
+ A read no longer trusts the query's own address alone. It scans the
320
+ addresses within ``read_radius`` bit flips and asks each candidate cell how
321
+ well it explains the query, using the cell's own posterior variance:
322
+
323
+ $$ s(c) = q^\\top P_c q . $$
324
+
325
+ The smallest variance wins in each block, the smallest across blocks is
326
+ returned, and the value is that cell's prediction ``W_c q``. The signal is
327
+ the RLS cell's own uncertainty: a cell whose writers span the query scores
328
+ about one, an unwritten cell scores ``‖q‖² / λ``, and a cell crowded with
329
+ unrelated keys scores ``‖q‖² / (c + λ)``. It therefore prefers crowded
330
+ cells, which is exactly the regime where their map has the information to
331
+ answer; only when the crowding exceeds the key dimension does the
332
+ distinction disappear.
333
+
334
+ Because a write places a copy within ``write_radius`` flips and a read
335
+ searches ``read_radius`` flips, the two radii add: a query survives up to
336
+ ``write_radius + read_radius`` corrupted bits. Read redundancy costs
337
+ probes, not cells, so it does not inflate the collision rate the way
338
+ write-side replication does.
339
+
340
+ Args:
341
+ read_radius: Hamming radius searched at read time. With
342
+ ``write_radius=1`` the default ``read_radius=1`` corrects two
343
+ corrupted bits.
344
+ protect_bits: if set, a write replicates only the ``protect_bits``
345
+ lowest-margin bits instead of every bit within ``write_radius``.
346
+ Margins are standard normal for these projections, so the lowest
347
+ ones are the bits key noise is most likely to flip.
348
+ seed: seed of the projection generator.
349
+ dtype: state dtype, promoted to at least float32.
350
+ """
351
+
352
+ def __init__(
353
+ self,
354
+ key_dim: int,
355
+ value_dim: int,
356
+ n_blocks: int = 3,
357
+ n_bits: int = 12,
358
+ write_radius: int = 1,
359
+ read_radius: int = 1,
360
+ protect_bits: int | None = None,
361
+ rr_lambda: float = 1.0,
362
+ seed: int = 0,
363
+ dtype: torch.dtype | None = None,
364
+ ) -> None:
365
+ super().__init__(
366
+ key_dim,
367
+ value_dim,
368
+ n_blocks=n_blocks,
369
+ n_bits=n_bits,
370
+ write_radius=write_radius,
371
+ rr_lambda=rr_lambda,
372
+ seed=seed,
373
+ dtype=dtype,
374
+ )
375
+ if not 0 <= read_radius <= n_bits:
376
+ raise ValueError(
377
+ f"read_radius must lie in [0, {n_bits}], got {read_radius}"
378
+ )
379
+ if protect_bits is not None and not 0 <= protect_bits <= n_bits:
380
+ raise ValueError(
381
+ f"protect_bits must lie in [0, {n_bits}] or be None, "
382
+ f"got {protect_bits}"
383
+ )
384
+ self.read_radius = read_radius
385
+ self.protect_bits = protect_bits
386
+
387
+ def _risky_bits(self, key: torch.Tensor) -> torch.Tensor:
388
+ """``True`` for the ``protect_bits`` lowest-margin bits of each key."""
389
+ margins = self.projections(key).abs().transpose(0, 1)
390
+ rank = margins.argsort(dim=-1).argsort(dim=-1)
391
+ return (rank < self.protect_bits).transpose(0, 1)
392
+
393
+ def _write_addresses(
394
+ self, key: torch.Tensor
395
+ ) -> list[tuple[torch.Tensor, torch.Tensor | None]]:
396
+ base = self.addresses(key)
397
+ masks = _flip_masks(self.n_bits, self.write_radius)
398
+ if self.protect_bits is None:
399
+ return [(base ^ _mask_value(mask), None) for mask in masks]
400
+ risky = self._risky_bits(key)
401
+ plan = []
402
+ for mask in masks:
403
+ valid = None
404
+ if mask:
405
+ valid = torch.stack([risky[..., bit] for bit in mask]).all(dim=0)
406
+ plan.append((base ^ _mask_value(mask), valid))
407
+ return plan
408
+
409
+ def _select(self, key: torch.Tensor, probes: list[torch.Tensor]) -> CellRead:
410
+ """Score every probe cell, keep the least uncertain one per block."""
411
+ batch = key.shape[0]
412
+ best_score = torch.full(
413
+ (batch, self.n_blocks),
414
+ float("inf"),
415
+ dtype=self.cells.covariance.dtype,
416
+ )
417
+ best_index = torch.zeros(
418
+ (batch, self.n_blocks), dtype=torch.int64, device=key.device
419
+ )
420
+ keys = key[:, None, :].expand(-1, self.n_blocks, -1)
421
+ for address in probes:
422
+ index = self._flat_indices(address)
423
+ score = self.cells.variance(index, keys)
424
+ better = score < best_score
425
+ best_score = torch.where(better, score, best_score)
426
+ best_index = torch.where(better, index, best_index)
427
+ return CellRead(
428
+ values=self.cells.predict(best_index, keys).transpose(0, 1),
429
+ scores=best_score.transpose(0, 1),
430
+ indices=best_index.transpose(0, 1),
431
+ )
432
+
433
+ def read_ball(self, key: torch.Tensor, radius: int | None = None) -> CellRead:
434
+ """Verify every cell within ``radius`` flips of the query's address."""
435
+ radius = self.read_radius if radius is None else radius
436
+ if not 0 <= radius <= self.n_bits:
437
+ raise ValueError(
438
+ f"radius must lie in [0, {self.n_bits}], got {radius}"
439
+ )
440
+ base = self.addresses(key)
441
+ probes = [
442
+ base ^ _mask_value(mask)
443
+ for mask in _flip_masks(self.n_bits, radius)
444
+ ]
445
+ return self._select(key, probes)
446
+
447
+ def read_guided(
448
+ self, key: torch.Tensor, search_bits: int = 6
449
+ ) -> CellRead:
450
+ """Verify every flip pattern of the least-confident ``search_bits`` bits.
451
+
452
+ Cost is ``2 ** search_bits`` probes per block instead of
453
+ ``sum(C(n_bits, j) for j <= radius)``, and it does not assume an upper
454
+ bound on the number of flipped bits: a flip in a bit with a large
455
+ margin is simply not searched.
456
+ """
457
+ if not 1 <= search_bits <= min(self.n_bits, 12):
458
+ raise ValueError(
459
+ f"search_bits must lie in [1, {min(self.n_bits, 12)}], "
460
+ f"got {search_bits}"
461
+ )
462
+ base = self.addresses(key)
463
+ order = self.projections(key).abs().argsort(dim=-1)[..., :search_bits]
464
+ flip = (1 << order).unsqueeze(2) # (batch, n_blocks, 1, search_bits)
465
+ patterns = torch.tensor(
466
+ list(product((0, 1), repeat=search_bits)), device=base.device
467
+ )
468
+ flips = (flip * patterns).sum(dim=-1) # (batch, n_blocks, 2**search_bits)
469
+ probes = [base ^ flips[..., i] for i in range(patterns.shape[0])]
470
+ return self._select(key, probes)
471
+
472
+ def forward(self, key: torch.Tensor) -> torch.Tensor:
473
+ """Read the best-verified cell, shape ``(batch, value_dim)``."""
474
+ return self.read_ball(key).best()