matchlab 0.1.0.dev1__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.
Files changed (72) hide show
  1. matchlab/__init__.py +55 -0
  2. matchlab/adapters/__init__.py +24 -0
  3. matchlab/adapters/base.py +446 -0
  4. matchlab/adapters/duckdb.py +659 -0
  5. matchlab/cli.py +177 -0
  6. matchlab/core/__init__.py +1 -0
  7. matchlab/core/dataframes.py +254 -0
  8. matchlab/core/dsu.py +62 -0
  9. matchlab/core/exceptions.py +40 -0
  10. matchlab/core/hash.py +141 -0
  11. matchlab/core/kinds.py +12 -0
  12. matchlab/core/logging.py +190 -0
  13. matchlab/core/resolver_output.py +173 -0
  14. matchlab/core/schemas.py +82 -0
  15. matchlab/core/sql.py +12 -0
  16. matchlab/document.py +329 -0
  17. matchlab/eval/__init__.py +11 -0
  18. matchlab/eval/judgements.py +41 -0
  19. matchlab/eval/metrics.py +244 -0
  20. matchlab/eval/samples.py +415 -0
  21. matchlab/eval/tui/__init__.py +66 -0
  22. matchlab/eval/tui/app.py +418 -0
  23. matchlab/eval/tui/modals.py +84 -0
  24. matchlab/eval/tui/styles.tcss +184 -0
  25. matchlab/eval/tui/widgets/__init__.py +1 -0
  26. matchlab/eval/tui/widgets/assignment.py +72 -0
  27. matchlab/eval/tui/widgets/styling.py +75 -0
  28. matchlab/eval/tui/widgets/table.py +285 -0
  29. matchlab/lineage.py +239 -0
  30. matchlab/locations.py +347 -0
  31. matchlab/models/__init__.py +10 -0
  32. matchlab/models/comparison.py +67 -0
  33. matchlab/models/dedupers/__init__.py +5 -0
  34. matchlab/models/dedupers/base.py +38 -0
  35. matchlab/models/dedupers/naive.py +77 -0
  36. matchlab/models/linkers/__init__.py +9 -0
  37. matchlab/models/linkers/base.py +41 -0
  38. matchlab/models/linkers/deterministic.py +229 -0
  39. matchlab/models/linkers/splinklinker.py +262 -0
  40. matchlab/models/linkers/weighteddeterministic.py +143 -0
  41. matchlab/models/models.py +198 -0
  42. matchlab/progress.py +455 -0
  43. matchlab/recordstep.py +181 -0
  44. matchlab/resolvers/__init__.py +10 -0
  45. matchlab/resolvers/base.py +63 -0
  46. matchlab/resolvers/components.py +66 -0
  47. matchlab/resolvers/resolvers.py +409 -0
  48. matchlab/sources.py +291 -0
  49. matchlab/specs.py +129 -0
  50. matchlab/steps.py +284 -0
  51. matchlab/testkit/__init__.py +104 -0
  52. matchlab/testkit/_generate.py +240 -0
  53. matchlab/testkit/compare.py +178 -0
  54. matchlab/testkit/entities.py +290 -0
  55. matchlab/testkit/features.py +146 -0
  56. matchlab/testkit/linked.py +527 -0
  57. matchlab/testkit/matchers.py +233 -0
  58. matchlab/testkit/models.py +165 -0
  59. matchlab/testkit/sources.py +327 -0
  60. matchlab/transformers/__init__.py +24 -0
  61. matchlab/transformers/base.py +41 -0
  62. matchlab/transformers/clean.py +48 -0
  63. matchlab/transformers/explode.py +67 -0
  64. matchlab/transformers/group.py +56 -0
  65. matchlab/transformers/select.py +31 -0
  66. matchlab/transformers/transform.py +101 -0
  67. matchlab-0.1.0.dev1.dist-info/METADATA +88 -0
  68. matchlab-0.1.0.dev1.dist-info/RECORD +72 -0
  69. matchlab-0.1.0.dev1.dist-info/WHEEL +5 -0
  70. matchlab-0.1.0.dev1.dist-info/entry_points.txt +2 -0
  71. matchlab-0.1.0.dev1.dist-info/licenses/LICENSE +21 -0
  72. matchlab-0.1.0.dev1.dist-info/top_level.txt +1 -0
matchlab/__init__.py ADDED
@@ -0,0 +1,55 @@
1
+ """The matchlab plan API.
2
+
3
+ Build a plan from a `Source`, chain verbs, then `collect()`:
4
+
5
+ ```python
6
+ from matchlab import Source
7
+
8
+ deduped = source.clean(...).dedupe(...).resolve()
9
+ lookup = deduped.collect().get_lookup()
10
+ ```
11
+
12
+ Nothing runs until `collect()`, and collecting again only does the work whose plan
13
+ or inputs changed.
14
+ """
15
+
16
+ from importlib.metadata import version
17
+
18
+ from matchlab.document import PlanDocument, dump, load
19
+ from matchlab.locations import RelationalDBLocation
20
+ from matchlab.models import Model
21
+ from matchlab.recordstep import RecordStep
22
+ from matchlab.resolvers import Resolver
23
+ from matchlab.sources import Source
24
+ from matchlab.steps import Step, default_adapter, set_default_adapter
25
+ from matchlab.transformers import (
26
+ Clean,
27
+ Group,
28
+ Select,
29
+ Transform,
30
+ Transformer,
31
+ add_transformer_class,
32
+ )
33
+
34
+ __version__ = version("matchlab")
35
+
36
+ __all__ = (
37
+ "Clean",
38
+ "Group",
39
+ "Model",
40
+ "PlanDocument",
41
+ "RecordStep",
42
+ "RelationalDBLocation",
43
+ "Resolver",
44
+ "Select",
45
+ "Source",
46
+ "Step",
47
+ "Transform",
48
+ "Transformer",
49
+ "__version__",
50
+ "add_transformer_class",
51
+ "default_adapter",
52
+ "dump",
53
+ "load",
54
+ "set_default_adapter",
55
+ )
@@ -0,0 +1,24 @@
1
+ """Storage adapters for matchlab.
2
+
3
+ An adapter persists collected DAG-step artifacts keyed by fingerprint. It is storage,
4
+ not an engine that resolves on demand. `DuckDBAdapter` is the reference implementation.
5
+ """
6
+
7
+ from matchlab.adapters.base import (
8
+ Adapter,
9
+ Fingerprint,
10
+ PruneResult,
11
+ StoreStats,
12
+ format_bytes,
13
+ )
14
+ from matchlab.adapters.duckdb import DuckDBAdapter, DuckDBStoreStats
15
+
16
+ __all__ = (
17
+ "Adapter",
18
+ "DuckDBAdapter",
19
+ "DuckDBStoreStats",
20
+ "Fingerprint",
21
+ "PruneResult",
22
+ "StoreStats",
23
+ "format_bytes",
24
+ )
@@ -0,0 +1,446 @@
1
+ """The storage adapter contract for matchlab.
2
+
3
+ An adapter is **storage, not an engine**. It persists the artifacts each collected DAG
4
+ step produces, keyed by that step's content fingerprint, and reads them back. It does
5
+ *not* resolve anything on demand. Resolvers materialise their complete, merge-forward
6
+ output at collect time and hand the adapter a finished table.
7
+
8
+ Artifacts, by step kind (schemas in `matchlab.core.schemas`, which holds exactly the
9
+ shapes that cross this boundary):
10
+
11
+ * Source → warehouse extract (arbitrary schema) + leaf assignment `(key, leaf)`.
12
+ * Transform → the reshaped record step it materialises (arbitrary schema).
13
+ * Model → edge list, `SCHEMA_MODEL_EDGES` `(left_id, right_id, score)`.
14
+ * Resolver → complete flat output, `SCHEMA_RESOLVER_OUTPUT` `(root, leaf, key, src)`.
15
+ This is the merge-forward guarantee. It computes `merge(upstream complete
16
+ output, own clusters)`, not just the resolver's own clusters.
17
+
18
+ A `Source` and a `Resolver` produce a record step too, but neither stores one. A source
19
+ is fully materialised as its *extract* and derives its record step on read. A resolver
20
+ read is a re-derivable join over the source's extract and the resolver's own output.
21
+ Only a `Transform` stores its materialisation.
22
+
23
+ Plus evaluation storage (judgements + cluster expansion), publication (`publish` points
24
+ a label at a resolver's output) and `close`.
25
+
26
+ **Nothing here deletes an artifact on the store's own initiative.** A store keeps what
27
+ it is given until the owner disposes of it. `prune` is that disposal. It deletes only
28
+ what the caller has said it may, and never a published resolver output. See the guide's
29
+ "Reclaiming storage".
30
+ """
31
+
32
+ from abc import ABC, abstractmethod
33
+ from collections.abc import Iterable, Mapping
34
+
35
+ import polars as pl
36
+ from pydantic import BaseModel, ConfigDict, Field
37
+
38
+ from matchlab.core.dataframes import qualify
39
+ from matchlab.core.kinds import StepKind
40
+ from matchlab.eval.judgements import Judgement
41
+
42
+ Fingerprint = bytes
43
+
44
+
45
+ class StoreStats(BaseModel):
46
+ """What a store holds, and what it costs.
47
+
48
+ A store keeps everything collected into it, so it grows with every plan and every
49
+ edit to one. Adapters subclass this to report metrics that are relevant to them.
50
+
51
+ Attributes:
52
+ location: Where the store is, as a reader would name it, such as a path, a
53
+ URI, or `":memory:"`. Always present, because a store you cannot point at
54
+ is one you cannot go and delete.
55
+ bytes: The store's size. For anything file-backed, this is a **high-water
56
+ mark**. A store that briefly held 4 GB reports 4 GB after the rows are
57
+ gone, because deleting inside a database file does not return space to the
58
+ OS. That is the right number for a disk filling up, and the wrong one for
59
+ "how much data do I have".
60
+ artifacts: How many artifacts of each step kind are stored.
61
+ labels: How many published labels point into the store.
62
+ """
63
+
64
+ model_config = ConfigDict(frozen=True)
65
+
66
+ location: str
67
+ bytes: int
68
+ artifacts: dict[StepKind, int] = Field(default_factory=dict)
69
+ labels: int = 0
70
+
71
+ @property
72
+ def size(self) -> str:
73
+ """This store's size, as a phrase.
74
+
75
+ A hook, because a bare figure can mislead. Bytes held in memory and bytes
76
+ written to disk are not the same claim, and only the backend knows which one
77
+ it just reported.
78
+ """
79
+ return format_bytes(self.bytes)
80
+
81
+ def describe(self, since: "StoreStats | None" = None) -> str:
82
+ """One clause saying what the store costs, for a collect to print.
83
+
84
+ Args:
85
+ since: An earlier reading, so the growth between them can be attributed to
86
+ whatever happened in between. Without it the figure is the store's size
87
+ and nothing more, which names no cause.
88
+ """
89
+ count = sum(self.artifacts.values())
90
+ growth = (
91
+ f" ({format_bytes(self.bytes - since.bytes, signed=True)})"
92
+ if since is not None
93
+ else ""
94
+ )
95
+ return f"Store {self.size}{growth}, {count} artifact{'' if count == 1 else 's'}"
96
+
97
+
98
+ class PruneResult(BaseModel):
99
+ """What a prune removed, and what that actually recovered.
100
+
101
+ Adapters subclass this where they have more to say.
102
+
103
+ Attributes:
104
+ removed: How many artifacts were deleted.
105
+ kept: How many survived. This includes the ones named, the ones their lineage
106
+ needed, and everything published.
107
+ reclaimed: Bytes genuinely returned. **Measured** as the store's size before
108
+ minus after, not totted up from what was deleted. The two are not the same
109
+ number. Deleting inside a database file usually frees nothing at all until
110
+ the file is rewritten, and a reclaim that reported the bytes it *deleted*
111
+ would claim to have freed space while the disk sat unchanged.
112
+ """
113
+
114
+ model_config = ConfigDict(frozen=True)
115
+
116
+ removed: int
117
+ kept: int
118
+ reclaimed: int
119
+
120
+ def describe(self) -> str:
121
+ """One line saying what happened, for a caller to print."""
122
+ return (
123
+ f"Removed {self.removed} artifact{'' if self.removed == 1 else 's'}, "
124
+ f"kept {self.kept}, reclaimed {format_bytes(self.reclaimed)}"
125
+ )
126
+
127
+
128
+ def format_bytes(count: int, *, signed: bool = False) -> str:
129
+ """Render a byte count the way someone reading a disk would.
130
+
131
+ Binary units, because that is what a file browser and `du -h` report, and a size
132
+ that disagrees with the one the user can check is worse than no size at all.
133
+ """
134
+ sign = "+" if signed and count >= 0 else "-" if count < 0 else ""
135
+ size = float(abs(count))
136
+ for unit in ("B", "KB", "MB", "GB", "TB"):
137
+ if size < 1024 or unit == "TB":
138
+ # Whole bytes never want a decimal point. Larger units always do, so a
139
+ # store that grew stays visibly distinct from one that didn't.
140
+ precision = 0 if unit == "B" else 1
141
+ return f"{sign}{size:.{precision}f} {unit}"
142
+ size /= 1024
143
+ raise AssertionError("unreachable: the loop returns at TB")
144
+
145
+
146
+ class Adapter(ABC):
147
+ """Fingerprint-keyed storage for collected DAG-step artifacts.
148
+
149
+ Implementations are single-user and local. `DuckDBAdapter` is the reference.
150
+ """
151
+
152
+ # -- existence / caching ----------------------------------------------------------
153
+
154
+ @abstractmethod
155
+ def has(self, fp: Fingerprint) -> bool:
156
+ """Return whether an artifact for this fingerprint is already stored.
157
+
158
+ The client uses this to skip re-running a step whose plan is unchanged.
159
+ """
160
+ ...
161
+
162
+ # -- sources ----------------------------------------------------------------------
163
+
164
+ @abstractmethod
165
+ def store_source(
166
+ self,
167
+ fp: Fingerprint,
168
+ key_field: str,
169
+ extract: pl.DataFrame,
170
+ leaves: pl.DataFrame,
171
+ ) -> None:
172
+ """Store a collected source.
173
+
174
+ Args:
175
+ fp: The source step's fingerprint.
176
+ key_field: Which column of `extract` holds the key. Stored so the extract
177
+ can be read back and joined to a resolver's output without the plan.
178
+ extract: The warehouse extract (arbitrary schema) to cache.
179
+ leaves: The leaf assignment, columns `(key: str, leaf: uint64)`.
180
+ """
181
+ ...
182
+
183
+ @abstractmethod
184
+ def read_source_extract(self, fp: Fingerprint) -> pl.DataFrame:
185
+ """Return the cached warehouse extract for a stored source."""
186
+ ...
187
+
188
+ @abstractmethod
189
+ def read_source_leaves(self, fp: Fingerprint) -> pl.DataFrame:
190
+ """Return the `(key, leaf)` assignment for a stored source."""
191
+ ...
192
+
193
+ # -- identifiers ------------------------------------------------------------------
194
+
195
+ @abstractmethod
196
+ def read_identifiers(
197
+ self,
198
+ source_fp: Fingerprint,
199
+ source_name: str,
200
+ resolver_fp: Fingerprint | None = None,
201
+ ) -> pl.DataFrame:
202
+ """Return `(id, source, key, leaf)` for one source's records.
203
+
204
+ `id` is `resolver_fp`'s root cluster when reading through a resolver, otherwise
205
+ the source's own leaf. This is the *upstream output* a downstream resolver
206
+ needs in order to carry every reachable leaf forward, including records no model
207
+ matched.
208
+
209
+ A query rather than an artifact. Nothing here is computed. Both readings are
210
+ projections of tables this store already holds, `source_leaves` and
211
+ `resolver_output`. There is nothing to cache, and caching it under a record
212
+ step's fingerprint would be wrong anyway. The result depends only on the source
213
+ and resolver read, not on how a record step reshapes the data.
214
+
215
+ Args:
216
+ source_fp: Fingerprint of the stored source whose records are wanted.
217
+ source_name: The source's name, which is returned in the `source` column
218
+ and tags each row for the resolver output below.
219
+ resolver_fp: Fingerprint of the resolver to read through, or `None` to read
220
+ the source's own leaves.
221
+ """
222
+ ...
223
+
224
+ # -- models -----------------------------------------------------------------------
225
+
226
+ @abstractmethod
227
+ def store_model(self, fp: Fingerprint, edges: pl.DataFrame) -> None:
228
+ """Store a model's edge list (`SCHEMA_MODEL_EDGES`)."""
229
+ ...
230
+
231
+ @abstractmethod
232
+ def read_model(self, fp: Fingerprint) -> pl.DataFrame:
233
+ """Return a stored model's edge list."""
234
+ ...
235
+
236
+ # -- transforms -------------------------------------------------------------------
237
+
238
+ @abstractmethod
239
+ def store_transform(self, fp: Fingerprint, table: pl.DataFrame) -> None:
240
+ """Store a transform's materialised record step (arbitrary schema).
241
+
242
+ Called on every collect, so a transform feeding several models is computed
243
+ once and read back by each.
244
+ """
245
+ ...
246
+
247
+ @abstractmethod
248
+ def read_transform(self, fp: Fingerprint) -> pl.DataFrame:
249
+ """Return a stored transform's record step."""
250
+ ...
251
+
252
+ # -- resolvers --------------------------------------------------------------------
253
+
254
+ @abstractmethod
255
+ def store_resolver(
256
+ self,
257
+ fp: Fingerprint,
258
+ resolver_output: pl.DataFrame,
259
+ sources: Mapping[str, Fingerprint] | None = None,
260
+ ) -> None:
261
+ """Store a resolver's complete flat output.
262
+
263
+ Args:
264
+ fp: The resolver step's fingerprint.
265
+ resolver_output: `SCHEMA_RESOLVER_OUTPUT` columns `(root, leaf, key,
266
+ source)`, already merged forward over all upstream leaves. The adapter
267
+ does not verify the merge. That is the client's contract. The adapter
268
+ does validate the schema.
269
+ sources: Source name to fingerprint, for every source this output covers. A
270
+ resolver's output names its sources but one store can hold several
271
+ generations of a name, so this records which were actually used.
272
+ """
273
+ ...
274
+
275
+ # -- labels -----------------------------------------------------------------------
276
+ #
277
+ # A **label** is a pointer, kept here, from a string you chose to a resolver output
278
+ # you want to find again. It is deliberately not called a name. A *name* belongs to
279
+ # a source and is part of its output. A label belongs to the store and is part of
280
+ # finding things in it. Storing an artifact and labelling one are separate acts.
281
+ # `Resolver.publish` does the second, after the first.
282
+ #
283
+ # This is what lets evaluation run against a store alone, with no plan.
284
+
285
+ @abstractmethod
286
+ def publish(self, label: str, fp: Fingerprint) -> None:
287
+ """Point `label` at `fp`, replacing whatever it pointed at before.
288
+
289
+ The adapter moves the pointer without arguing. The caller decides whether
290
+ overwriting is allowed, since it knows what the user asked for.
291
+ """
292
+ ...
293
+
294
+ @abstractmethod
295
+ def find(self, label: str) -> Fingerprint | None:
296
+ """Return the fingerprint a label points at, if any."""
297
+ ...
298
+
299
+ @abstractmethod
300
+ def labels(self) -> list[str]:
301
+ """Return every label in this store, sorted."""
302
+ ...
303
+
304
+ @abstractmethod
305
+ def source_key_field(self, fp: Fingerprint) -> str:
306
+ """Return which column of a stored source's extract holds the key."""
307
+ ...
308
+
309
+ def read_source_records(
310
+ self, source_fp: Fingerprint, source_name: str, keys: pl.Series
311
+ ) -> tuple[pl.DataFrame, str]:
312
+ """Return a source's stored rows for `keys`, every column qualified by name.
313
+
314
+ A query rather than an artifact, like `read_identifiers`, and concrete rather
315
+ than abstract because it is nothing but `source_key_field` and
316
+ `read_source_extract` composed.
317
+
318
+ The extract cached when the source was collected *is* the data the matching saw.
319
+ That is what you want in front of you when judging a resolver's output, and it
320
+ means both reading an entity and reviewing one work with no warehouse
321
+ connection.
322
+
323
+ Args:
324
+ source_fp: Fingerprint of the stored source whose rows are wanted.
325
+ source_name: The source's name, which every column is prefixed with.
326
+ keys: The keys to return rows for.
327
+
328
+ Returns:
329
+ `(rows, qualified key column)`.
330
+ """
331
+ key_field = self.source_key_field(source_fp)
332
+ extract = self.read_source_extract(source_fp)
333
+ qualified = extract.select(pl.all().name.prefix(qualify(source_name)))
334
+ qualified_key = qualify(source_name, key_field)
335
+ rows = qualified.with_columns(pl.col(qualified_key).cast(pl.Utf8)).filter(
336
+ pl.col(qualified_key).is_in(keys.implode())
337
+ )
338
+ return rows, qualified_key
339
+
340
+ @abstractmethod
341
+ def resolver_output_sources(self, fp: Fingerprint) -> dict[str, Fingerprint]:
342
+ """Return source name to fingerprint for a stored resolver output."""
343
+ ...
344
+
345
+ @abstractmethod
346
+ def read_resolver(self, fp: Fingerprint) -> pl.DataFrame:
347
+ """Return a resolver's stored output `(root, leaf, key, source)`."""
348
+ ...
349
+
350
+ # -- evaluation -------------------------------------------------------------------
351
+
352
+ @abstractmethod
353
+ def store_judgement(self, judgement: Judgement, user_name: str = "local") -> None:
354
+ """Persist a user judgement, expanding its clusters to leaves for scoring."""
355
+ ...
356
+
357
+ @abstractmethod
358
+ def read_eval_data(
359
+ self, tag: str | None = None
360
+ ) -> tuple[pl.DataFrame, pl.DataFrame]:
361
+ """Return `(judgements, expansion)` tables for `matchlab.core.eval`.
362
+
363
+ `judgements` follows `SCHEMA_JUDGEMENTS`. `expansion` follows
364
+ `SCHEMA_CLUSTER_EXPANSION`. Both filter to `tag` when it is given.
365
+ """
366
+ ...
367
+
368
+ @abstractmethod
369
+ def sample(
370
+ self, resolver_fp: Fingerprint, n: int, seed: int | None = None
371
+ ) -> pl.DataFrame:
372
+ """Sample up to `n` clusters from a stored resolver output for evaluation.
373
+
374
+ Returns `SCHEMA_RESOLVER_OUTPUT` rows `(root, leaf, key, source)` for the
375
+ sampled roots.
376
+ """
377
+ ...
378
+
379
+ # -- introspection ----------------------------------------------------------------
380
+
381
+ @abstractmethod
382
+ def stats(self) -> StoreStats:
383
+ """Report the store's size and contents.
384
+
385
+ Abstract rather than an optional hook like `close`, because there is no honest
386
+ default. A store that reported zero would be wrong in exactly the place a user
387
+ is being asked to trust a number, and a store that cannot say how much room it
388
+ is taking is not one to hand a growing cache to.
389
+
390
+ Called on every collect, so it must be cheap. It may also settle pending writes
391
+ to report a size that has stopped moving, since `DuckDBAdapter` checkpoints.
392
+ This is not guaranteed to be a pure read.
393
+ """
394
+ ...
395
+
396
+ # -- maintenance ------------------------------------------------------------------
397
+
398
+ @abstractmethod
399
+ def prune(self, keep: Iterable[Fingerprint | str] = ()) -> PruneResult:
400
+ """Delete every artifact except the ones named, and reclaim what that frees.
401
+
402
+ **You say what to keep. Nothing is inferred.** This is deliberately not the
403
+ `gc()` that used to live here. That one worked out what was still alive from
404
+ which Python objects happened to be reachable. A fresh interpreter has nothing
405
+ reachable, so it considered the whole store garbage and duly emptied one
406
+ that had a published resolver output in it. An artifact's worth has nothing to
407
+ do with whether some process is holding the variable that produced it. The root
408
+ set therefore arrives as an argument, from a caller who has just named it.
409
+
410
+ Both kinds of name are ones a store already understands. A plan is different.
411
+ Which artifacts belong to one is the plan's business, and `Step.fingerprints()`
412
+ answers that question. Storage should not have to learn to walk a graph in
413
+ order to know what it may delete.
414
+
415
+ **Published labels are kept whether or not they are named**, along with the
416
+ sources their resolver output needs in order to stay readable. Publishing is the
417
+ strongest "keep this" the system has, and losing one because a caller forgot to
418
+ list it would be the old mistake in new clothes.
419
+
420
+ Implementations must:
421
+
422
+ * never touch stored judgements, which are human work and cannot be recomputed
423
+ from anything
424
+ * report `reclaimed` as space genuinely returned, measured rather than assumed.
425
+ Deleting is not always the same as reclaiming, and a store that says it freed
426
+ something it did not is worse than one that frees nothing.
427
+
428
+ Args:
429
+ keep: What to preserve. Either a `Fingerprint`, or a `str` naming a
430
+ published label, which keeps that resolver output and the sources it
431
+ reads through.
432
+
433
+ Returns:
434
+ What was removed and what that recovered.
435
+
436
+ Raises:
437
+ ValueError: If nothing would survive the prune, because `keep` was empty
438
+ and nothing is published. Deleting everything is the same as deleting
439
+ the file, and should not be what an accidentally-empty list does.
440
+ """
441
+ ...
442
+
443
+ # -- lifecycle --------------------------------------------------------------------
444
+
445
+ def close(self) -> None: # noqa: B027 - optional concrete hook, not abstract
446
+ """Release any underlying resources. Override if needed."""