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/document.py ADDED
@@ -0,0 +1,329 @@
1
+ """Serialise a plan to a portable document, and rebuild it somewhere else.
2
+
3
+ A `PlanDocument` is a **derived view** of a plan, not a source format. Humans write
4
+ Python, and a document is what you hand to another environment so it can run the same
5
+ plan. It is dumped, transferred and loaded, never hand-authored. That is why nodes
6
+ refer to each other by position rather than by any human-facing name.
7
+
8
+ Nodes come out in `lineage.walk` order, so every input index is smaller than the index
9
+ of the step that consumes it. Positions also preserve structural sharing. A record step
10
+ feeding two models is one node referenced twice. Nesting each step's inputs inside it
11
+ would have inlined the whole subtree twice over instead.
12
+
13
+ **Edges live here, settings live in the spec.** That split is the point of this
14
+ module. A spec is hashed into a fingerprint, so it must carry everything that changes
15
+ the step's output and nothing else. A serialised plan must also carry the edges, which
16
+ a fingerprint already covers by folding in its parents' fingerprints. Putting both in
17
+ one model would have forced a choice: a spec that lied about identity, where a rename
18
+ invalidates a subtree that produces identical bytes, or a document that could not be
19
+ rebuilt.
20
+
21
+ Edges are not the only thing that lands on this side of the split. A source's
22
+ `LocationRef` says which location class to build and under what name, so that `load`
23
+ can attach a client. Neither fact changes a byte the source produces, which is why a
24
+ `SourceSpec` records nothing about where its rows came from.
25
+
26
+ **What a document cannot carry:**
27
+
28
+ * *Clients.* A document names the locations its sources read and stops there. `load`
29
+ takes the clients and attaches them by name, so credentials never enter a document.
30
+ * *Code.* `model_class` and `resolver_class` are registry names, so the target
31
+ environment must have the same classes registered (`add_model_class`). A document is
32
+ portable across environments, not across codebases.
33
+ * *Labels.* Publishing is something you do to a *result*, using `Resolver.publish`. It
34
+ is not part of the plan, so the receiving environment collects and then publishes
35
+ under whatever label it wants. The only names in a document are sources' names,
36
+ which are part of their output.
37
+ * *Data, or any hash of it.* A source's fingerprint folds in a content hash of what it
38
+ actually read, and that hash is derived on load from the target warehouse. Same rows
39
+ give the same fingerprints, so the target store hits cache instead of recomputing.
40
+ Different rows give different fingerprints, so it re-runs. Both are the intended
41
+ behaviour.
42
+ """
43
+
44
+ from collections.abc import Mapping
45
+ from typing import Any, TypeVar, cast
46
+
47
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
48
+
49
+ from matchlab.core.kinds import StepKind
50
+ from matchlab.lineage import walk
51
+ from matchlab.locations import build_location
52
+ from matchlab.models import Model
53
+ from matchlab.recordstep import RecordStep
54
+ from matchlab.resolvers import Resolver
55
+ from matchlab.sources import Source
56
+ from matchlab.specs import (
57
+ ModelSpec,
58
+ ResolverSpec,
59
+ SourceSpec,
60
+ TransformSpec,
61
+ )
62
+ from matchlab.steps import Step
63
+ from matchlab.transformers import Transform
64
+
65
+ StepSpec = SourceSpec | TransformSpec | ModelSpec | ResolverSpec
66
+
67
+ SpecT = TypeVar("SpecT", bound=BaseModel)
68
+ StepT = TypeVar("StepT", bound=Step)
69
+
70
+ _SPEC_TYPES: dict[StepKind, type[BaseModel]] = {
71
+ StepKind.SOURCE: SourceSpec,
72
+ StepKind.TRANSFORM: TransformSpec,
73
+ StepKind.MODEL: ModelSpec,
74
+ StepKind.RESOLVER: ResolverSpec,
75
+ }
76
+
77
+
78
+ class LocationRef(BaseModel):
79
+ """Everything needed to rebuild a source's location."""
80
+
81
+ model_config = ConfigDict(frozen=True)
82
+
83
+ location_class: str = Field(
84
+ description="The registered name of the Location subclass to build."
85
+ )
86
+ name: str = Field(
87
+ description="The key `load` looks up in `clients` to find this location's."
88
+ )
89
+
90
+
91
+ class StepNode(BaseModel):
92
+ """One step: what kind it is, what it specifies, and what it reads."""
93
+
94
+ model_config = ConfigDict(frozen=True)
95
+
96
+ kind: StepKind = Field(description="Which kind of step this is.")
97
+ spec: StepSpec = Field(
98
+ description="This step's own settings, exactly what its fingerprint hashes."
99
+ )
100
+ inputs: tuple[int, ...] = Field(
101
+ default=(),
102
+ description=(
103
+ "Positions of this step's inputs, in order. Always smaller than this "
104
+ "step's own position, and order matters: it is the order the fingerprint "
105
+ "folds parents in, and the order a linker's left and right arrive in."
106
+ ),
107
+ )
108
+ location: LocationRef | None = Field(
109
+ default=None,
110
+ description=(
111
+ "For a source, how to rebuild the location it reads. Here rather than in "
112
+ "the spec because it describes reconstruction rather than output. See "
113
+ "`LocationRef`. Every other kind of step leaves it unset."
114
+ ),
115
+ )
116
+
117
+ @model_validator(mode="after")
118
+ def _check_location(self) -> "StepNode":
119
+ """A source names a location. Nothing else has one to name."""
120
+ if self.kind is StepKind.SOURCE and self.location is None:
121
+ raise ValueError("A source step must name the location it reads.")
122
+ if self.kind is not StepKind.SOURCE and self.location is not None:
123
+ raise ValueError(f"A {self.kind} step must not name a location.")
124
+ return self
125
+
126
+ @model_validator(mode="before")
127
+ @classmethod
128
+ def _spec_from_kind(cls, data: Any) -> Any: # noqa: ANN401 - raw pydantic input
129
+ """Parse `spec` as the type `kind` names, rather than guessing.
130
+
131
+ The specs are structurally close enough that a plain union mis-assigns. A
132
+ `TransformSpec` and a `ModelSpec` overlap once optional settings default, so a
133
+ union could bind the wrong one. `kind` is the discriminator the document holds.
134
+ """
135
+ if not isinstance(data, dict):
136
+ return data
137
+ # `kind` is still whatever the input carried, a plain string, from JSON. It
138
+ # finds its entry anyway, because a `StrEnum` member hashes and compares as
139
+ # its value.
140
+ kind, spec = data.get("kind"), data.get("spec")
141
+ if isinstance(spec, dict) and kind in _SPEC_TYPES:
142
+ return {**data, "spec": _SPEC_TYPES[kind].model_validate(spec)}
143
+ return data
144
+
145
+
146
+ class PlanDocument(BaseModel):
147
+ """A whole plan: its steps in topological order, and the edges between them."""
148
+
149
+ model_config = ConfigDict(frozen=True)
150
+
151
+ steps: tuple[StepNode, ...] = Field(
152
+ description=(
153
+ "Every step reachable from the plan's apex, inputs before consumers."
154
+ )
155
+ )
156
+
157
+ @model_validator(mode="after")
158
+ def _check_edges(self) -> "PlanDocument":
159
+ """Reject a document whose edges do not point backwards at real steps."""
160
+ for position, node in enumerate(self.steps):
161
+ for target in node.inputs:
162
+ if not 0 <= target < position:
163
+ raise ValueError(
164
+ f"Step {position} ({node.kind}) reads step {target}, which is "
165
+ "not an earlier step. Inputs must precede their consumers."
166
+ )
167
+ return self
168
+
169
+
170
+ def dump(root: Step) -> PlanDocument:
171
+ """Describe `root` and everything it reads as a portable document.
172
+
173
+ Args:
174
+ root: The apex of the plan. Only what is reachable upstream of it is included,
175
+ exactly as `collect()` would run.
176
+
177
+ Returns:
178
+ The document. It holds no client, no credentials and no data.
179
+ """
180
+ ordered = walk(root)
181
+ position = {id(step): index for index, step in enumerate(ordered)}
182
+ return PlanDocument(
183
+ steps=tuple(
184
+ _node(step, tuple(position[id(parent)] for parent in step.upstream))
185
+ for step in ordered
186
+ )
187
+ )
188
+
189
+
190
+ def _node(step: Step, inputs: tuple[int, ...]) -> StepNode:
191
+ """Describe one step, checking its spec is the one its kind implies."""
192
+ spec_type = _SPEC_TYPES.get(step.kind)
193
+ if spec_type is None or not isinstance(step.spec, spec_type):
194
+ raise ValueError(
195
+ f"A {step.kind} step has no matching spec type. A new kind of step "
196
+ "needs an entry in `_SPEC_TYPES`."
197
+ )
198
+ return StepNode(
199
+ kind=step.kind,
200
+ spec=cast(StepSpec, step.spec),
201
+ inputs=inputs,
202
+ location=(
203
+ LocationRef(
204
+ location_class=type(step.location).__name__, name=step.location.name
205
+ )
206
+ if isinstance(step, Source)
207
+ else None
208
+ ),
209
+ )
210
+
211
+
212
+ def load(document: PlanDocument, clients: Mapping[str, Any]) -> Step:
213
+ """Rebuild a plan from a document, returning its apex.
214
+
215
+ Nothing is collected. This reconstructs the same lazy plan, so the returned step
216
+ fingerprints identically to the one that was dumped, given the same data.
217
+
218
+ Args:
219
+ document: A dumped plan.
220
+ clients: Location name to client, for every location the document's sources
221
+ name. A `RelationalDBLocation` takes a SQLAlchemy engine or an ADBC
222
+ connection.
223
+
224
+ Returns:
225
+ The plan's apex, the last step in the document.
226
+
227
+ Raises:
228
+ ValueError: If the document is empty, names a location with no client or an
229
+ unregistered location class, or wires a step to an input of the wrong kind.
230
+ """
231
+ if not document.steps:
232
+ raise ValueError("A plan document needs at least one step")
233
+
234
+ built: list[Step] = []
235
+ for position, node in enumerate(document.steps):
236
+ built.append(_rebuild(node, position, [built[i] for i in node.inputs], clients))
237
+ return built[-1]
238
+
239
+
240
+ def _rebuild(
241
+ node: StepNode, position: int, inputs: list[Step], clients: Mapping[str, Any]
242
+ ) -> Step:
243
+ """Rebuild one step from its node and its already-rebuilt inputs."""
244
+ match node.kind:
245
+ case StepKind.SOURCE:
246
+ spec = _expect(node, position, SourceSpec)
247
+ reference = cast(LocationRef, node.location)
248
+ if reference.name not in clients:
249
+ raise ValueError(
250
+ f"Source '{spec.name}' reads location '{reference.name}', which "
251
+ f"has no client. Pass one in `clients`: "
252
+ f"{{'{reference.name}': engine}}."
253
+ )
254
+ return Source(
255
+ location=build_location(
256
+ reference.location_class,
257
+ name=reference.name,
258
+ client=clients[reference.name],
259
+ ),
260
+ name=spec.name,
261
+ extract_transform=spec.extract_transform,
262
+ key_field=spec.key_field,
263
+ )
264
+
265
+ case StepKind.TRANSFORM:
266
+ spec = _expect(node, position, TransformSpec)
267
+ record_steps = _all_of(node, position, inputs, RecordStep)
268
+ if len(record_steps) != 1:
269
+ raise ValueError(
270
+ f"Step {position} (transform) reads {len(record_steps)} record "
271
+ "steps; a transform reshapes exactly one."
272
+ )
273
+ return Transform(
274
+ record_steps[0],
275
+ transformer=spec.transformer_class,
276
+ transformer_settings=spec.transformer_settings,
277
+ )
278
+
279
+ case StepKind.MODEL:
280
+ spec = _expect(node, position, ModelSpec)
281
+ record_steps = _all_of(node, position, inputs, RecordStep)
282
+ if not 1 <= len(record_steps) <= 2:
283
+ raise ValueError(
284
+ f"Step {position} (model) reads {len(record_steps)} record "
285
+ "steps; a deduper reads one and a linker two."
286
+ )
287
+ return Model(
288
+ left=record_steps[0],
289
+ right=record_steps[1] if len(record_steps) > 1 else None,
290
+ model_class=spec.model_class,
291
+ model_settings=spec.model_settings,
292
+ )
293
+
294
+ case StepKind.RESOLVER:
295
+ spec = _expect(node, position, ResolverSpec)
296
+ return Resolver(
297
+ *_all_of(node, position, inputs, Model),
298
+ resolver_class=spec.resolver_class,
299
+ resolver_settings=spec.resolver_settings,
300
+ )
301
+
302
+ case _: # every member of StepKind is handled above
303
+ raise ValueError(
304
+ f"No rebuild for a {node.kind} step. A new kind of step needs an arm "
305
+ "here as well as an entry in `_SPEC_TYPES`."
306
+ )
307
+
308
+
309
+ def _expect(node: StepNode, position: int, spec_type: type[SpecT]) -> SpecT:
310
+ """Narrow a node's spec to the type its kind implies."""
311
+ if not isinstance(node.spec, spec_type):
312
+ raise ValueError( # the validator parses spec by kind
313
+ f"Step {position} ({node.kind}) has a "
314
+ f"{type(node.spec).__name__}, expected {spec_type.__name__}."
315
+ )
316
+ return node.spec
317
+
318
+
319
+ def _all_of(
320
+ node: StepNode, position: int, inputs: list[Step], step_type: type[StepT]
321
+ ) -> tuple[StepT, ...]:
322
+ """Assert every input is the kind this step can read, and return them in order."""
323
+ checked = tuple(step for step in inputs if isinstance(step, step_type))
324
+ if len(checked) != len(inputs):
325
+ raise ValueError(
326
+ f"Step {position} ({node.kind}) must read only "
327
+ f"{step_type.__name__.lower()}s, got {[step.kind for step in inputs]}."
328
+ )
329
+ return checked
@@ -0,0 +1,11 @@
1
+ """Public evaluation helpers."""
2
+
3
+ from matchlab.eval.samples import EvalData, create_judgement, get_samples
4
+ from matchlab.eval.tui import review
5
+
6
+ __all__ = [
7
+ "EvalData",
8
+ "create_judgement",
9
+ "get_samples",
10
+ "review",
11
+ ]
@@ -0,0 +1,41 @@
1
+ """Representation of human judgements."""
2
+
3
+ from itertools import chain
4
+ from typing import Self
5
+
6
+ from pydantic import BaseModel, Field, model_validator
7
+
8
+
9
+ class Judgement(BaseModel):
10
+ """A user's decision on how one reviewed cluster's leaves split into entities."""
11
+
12
+ tag: str | None = None
13
+ shown: list[int] = Field(
14
+ description="Leaf IDs of the whole cluster shown to the user"
15
+ )
16
+ endorsed: list[list[int]] = Field(
17
+ description="Leaf IDs the user grouped together, one list per endorsed entity"
18
+ )
19
+
20
+ @model_validator(mode="after")
21
+ def check_no_duplicates(self) -> Self:
22
+ """Reject leaf IDs repeated within `shown`, or within `endorsed`."""
23
+ if len(self.shown) != len(set(self.shown)):
24
+ raise ValueError("One or more leaf IDs were repeated in the shown data")
25
+ concat_ids = list(chain(*self.endorsed))
26
+ if len(concat_ids) != len(set(concat_ids)):
27
+ raise ValueError(
28
+ "One or more leaf IDs were repeated in the endorsement data"
29
+ )
30
+
31
+ return self
32
+
33
+ @model_validator(mode="after")
34
+ def check_consistency(self) -> Self:
35
+ """Check that `endorsed`'s leaves are exactly the ones in `shown`."""
36
+ all_shown = set(self.shown)
37
+ all_endorsed = set(chain(*self.endorsed))
38
+ if all_shown != all_endorsed:
39
+ raise ValueError("Inconsistent leaf IDs between shown and endorsed")
40
+
41
+ return self
@@ -0,0 +1,244 @@
1
+ """Score a resolver's clusters against stored human judgements."""
2
+
3
+ from itertools import chain, combinations
4
+ from typing import TypeAlias
5
+
6
+ import polars as pl
7
+
8
+ Pair: TypeAlias = tuple[int, int]
9
+ Pairs: TypeAlias = set[Pair]
10
+
11
+ PrecisionRecall: TypeAlias = tuple[float, float]
12
+
13
+
14
+ def precision_recall(
15
+ models_root_leaf: list[pl.DataFrame],
16
+ judgements: pl.DataFrame,
17
+ expansion: pl.DataFrame,
18
+ ) -> list[PrecisionRecall]:
19
+ """Compute precision and recall for each model's clusters against judgements.
20
+
21
+ The function turns both model clusters and judgements into pairs of leaves, then
22
+ compares them:
23
+
24
+ - Convert each model's clusters and the judgements into pairwise connections
25
+ between leaves. For judgements, this includes pairs the user was shown but
26
+ rejected, not just the ones they endorsed. Each pair's net score is the number
27
+ of times it was endorsed, minus the number of times it was rejected.
28
+ - Keep only pairs whose leaves appear in every model and in the judgements, so
29
+ each model is compared over the same data.
30
+ - Drop a pair from both model and validation pairs if it was rejected as often as
31
+ it was endorsed. If it was rejected more often than endorsed, drop it from
32
+ validation pairs only, and keep it in model pairs.
33
+ - Compute precision and recall for each model against the remaining validation
34
+ pairs.
35
+
36
+ This ignores user IDs, so judgements are not yet weighted by reviewer.
37
+
38
+ Args:
39
+ models_root_leaf: One `(root, leaf)` table per model. Each must be
40
+ merge-forward, including every leaf reachable from the model's inputs,
41
+ even ones no model touched, which appear as their own root.
42
+ judgements: Table following `SCHEMA_JUDGEMENTS`.
43
+ expansion: Table following `SCHEMA_CLUSTER_EXPANSION`.
44
+
45
+ Returns:
46
+ One `(precision, recall)` pair per model, in the order given.
47
+ """
48
+ leaves_per_set: list[set[int]] = [] # one entry for each model, one for judgements
49
+ pairs_per_model: list[Pairs] = []
50
+
51
+ if not len(judgements):
52
+ raise ValueError("Judgements data cannot be empty.")
53
+
54
+ # Process models and judgements
55
+ for root_leaf in models_root_leaf:
56
+ if not len(root_leaf):
57
+ raise ValueError("Model data cannot be empty.")
58
+ leaves_per_set.append(set(root_leaf["leaf"].to_list()))
59
+ clusters = (
60
+ root_leaf.group_by("root")
61
+ .agg(pl.col("leaf").alias("leaves"))
62
+ .select("leaves")
63
+ .to_series()
64
+ .to_list()
65
+ )
66
+ model_pairs = set()
67
+ for c in clusters:
68
+ model_pairs.update(combinations(sorted(c), r=2))
69
+ pairs_per_model.append(model_pairs)
70
+
71
+ validation_pairs, validation_net_count, validation_leaves = process_judgements(
72
+ judgements, expansion
73
+ )
74
+ leaves_per_set.append(validation_leaves)
75
+
76
+ # Filter pairs to the leaves shared by every model and the judgements. For
77
+ # example, if model1 has (1,2), (1,3), (2,3), model2 has (1,10), (2,20), and
78
+ # judgements have (1), (2,3), only (1,2) is kept.
79
+ shared_leaves = set.intersection(*leaves_per_set)
80
+
81
+ for i, model_pairs in enumerate(pairs_per_model):
82
+ pairs_per_model[i] = {
83
+ (a, b)
84
+ for a, b in model_pairs
85
+ if (
86
+ a in shared_leaves
87
+ and b in shared_leaves
88
+ # Remove neutrally-judged pairs from the model
89
+ and validation_net_count[(a, b)] != 0
90
+ )
91
+ }
92
+
93
+ validation_pairs = {
94
+ (a, b)
95
+ for a, b in validation_pairs
96
+ if a in shared_leaves
97
+ and b in shared_leaves
98
+ # Remove all neutrally or negatively judged pairs
99
+ and validation_net_count[(a, b)] > 0
100
+ }
101
+
102
+ if not validation_pairs:
103
+ raise ValueError("Validation data has no pairs to evaluate.")
104
+
105
+ # Compute PR scores for each model
106
+ pr_scores: list[PrecisionRecall] = []
107
+ for i, model_pairs in enumerate(pairs_per_model):
108
+ true_positive_pairs = model_pairs & validation_pairs
109
+ if not model_pairs:
110
+ raise ValueError(f"Model at index {i} has no pairs to evaluate.")
111
+ pr_scores.append(
112
+ (
113
+ len(true_positive_pairs) / len(model_pairs),
114
+ len(true_positive_pairs) / len(validation_pairs),
115
+ )
116
+ )
117
+
118
+ return pr_scores
119
+
120
+
121
+ def process_judgements(
122
+ judgements: pl.DataFrame, expansion: pl.DataFrame
123
+ ) -> tuple[Pairs, dict[Pair, float], set[int]]:
124
+ """Turn judgements into leaf pairs, a net score per pair, and the leaves shown.
125
+
126
+ Expanding a cluster's leaves into every sorted pair is not quite enough on its
127
+ own. When a user is shown (123) and endorses (12), they also reject pairs (1,3)
128
+ and (2,3), not just endorse (1,2). Each pair's net score sums +1 for every
129
+ endorsement and -1 for every rejection implied this way.
130
+
131
+ This function assumes well-formed input:
132
+
133
+ - Every shown cluster ID has a matching row in `expansion`.
134
+ - Every endorsed cluster ID has a matching row in `expansion`, unless it is a
135
+ single leaf.
136
+ - No cluster is split across judgement rows unless every other part is expanded
137
+ too. For example, if (123)->(12) appears, (123)->(3) must appear too.
138
+
139
+ Args:
140
+ judgements: Table following `SCHEMA_JUDGEMENTS`.
141
+ expansion: Table following `SCHEMA_CLUSTER_EXPANSION`.
142
+
143
+ Returns:
144
+ A tuple of:
145
+
146
+ - Every pair implied by an endorsement or a rejection.
147
+ - Each pair's net score, where positive means endorsed more than rejected.
148
+ - Every leaf ID shown to a user.
149
+ """
150
+ expanded_judgements = (
151
+ judgements.join(expansion, left_on="shown", right_on="root")
152
+ .rename({"leaves": "shown_leaves"})
153
+ .join(
154
+ expansion, left_on="endorsed", right_on="root", how="left"
155
+ ) # left join as singleton leaves won't be expanded
156
+ .rename({"leaves": "endorsed_leaves"})
157
+ # Assume a missing expansion means a singleton leaf
158
+ .with_columns(
159
+ pl.when(pl.col("endorsed_leaves").is_null())
160
+ .then(
161
+ pl.col("endorsed").map_elements(
162
+ lambda x: [x], return_dtype=pl.List(pl.UInt64)
163
+ )
164
+ )
165
+ .otherwise(pl.col("endorsed_leaves"))
166
+ .alias("endorsed_leaves")
167
+ )
168
+ )
169
+
170
+ all_leaves = set(
171
+ chain.from_iterable(expanded_judgements["endorsed_leaves"].to_list())
172
+ )
173
+
174
+ if len(expanded_judgements) != len(judgements):
175
+ raise ValueError("Malformed judgements / expansion data received.")
176
+
177
+ validation_net_count: dict[Pair, float] = {}
178
+
179
+ for row in expanded_judgements.rows(named=True):
180
+ shown = row["shown_leaves"]
181
+ endorsed = row["endorsed_leaves"]
182
+
183
+ positive_pairs = set(combinations(sorted(endorsed), r=2))
184
+ potential_pairs = set(combinations(sorted(shown), r=2))
185
+ negative_pairs = potential_pairs - positive_pairs
186
+
187
+ # -- WORKED EXAMPLE --
188
+ # A user is shown cluster (1234) and endorses three groups: (1), (23), (4).
189
+ # This makes pair (2,3) good, and (1,2), (1,3), (1,4), (2,4), (3,4) bad.
190
+ # Target result: +1 for (2,3), -1 for every bad pair.
191
+
192
+ # The data does not arrive as one row holding the whole judgement. It arrives
193
+ # as one row per endorsed group, and these rows can be mixed with other users'
194
+ # judgements:
195
+ # Row A: shown (1234), endorsed (1)
196
+ # Row B: shown (1234), endorsed (23)
197
+ # Row C: shown (1234), endorsed (4)
198
+ # Each row below is scored on its own. The partial scores still add up to the
199
+ # right total, whatever order the rows arrive in.
200
+
201
+ # Row A: shown (1234), endorsed (1)
202
+ # - Potential pairs: (1,2) (1,3) (1,4) (2,3) (2,4) (3,4).
203
+ # - Endorsed pairs: none. A single item forms no pair.
204
+ # - Every potential pair is tentatively rejected, weight -1/4, because this
205
+ # endorsed group holds 1 of the 4 shown items.
206
+ # - Running scores: all pairs = -0.25.
207
+
208
+ # Row B: shown (1234), endorsed (23)
209
+ # - Endorsed pair (2,3) gets +1, plus +2/4 to cancel the rejection weight it
210
+ # took in rows A and C, the non-endorsed share of this group.
211
+ # Total for (2,3): +1 + 0.5 = +1.5.
212
+ # - Remaining rejected pairs take a further -2/4 = -0.5.
213
+ # - Running scores: (2,3) = -0.25 + 1.5 = +1.25, others = -0.25 - 0.5 = -0.75.
214
+
215
+ # Row C: shown (1234), endorsed (4)
216
+ # - Final -1/4 rejection weight lands on every potential pair.
217
+ # - Final scores: (2,3) = +1.25 - 0.25 = +1.0, others = -0.75 - 0.25 = -1.0.
218
+
219
+ # Result: (2,3) nets +1, matching the endorsement, and every rejected pair
220
+ # nets -1, whatever order the rows arrive in or however they interleave with
221
+ # other judgements, as long as no cluster is split without expanding its
222
+ # other part too.
223
+
224
+ # Subtract negative weight for all shown pairs not endorsed on this row
225
+ negative_adjustment = len(endorsed) / len(shown)
226
+ validation_net_count.update(
227
+ {
228
+ p: validation_net_count.get(p, 0) - negative_adjustment
229
+ for p in negative_pairs
230
+ }
231
+ )
232
+
233
+ # Add 1 plus positive weight for all pairs endorsed on this row
234
+ positive_adjustment = 1 + ((len(shown) - len(endorsed)) / len(shown))
235
+ validation_net_count.update(
236
+ {
237
+ p: validation_net_count.get(p, 0) + positive_adjustment
238
+ for p in positive_pairs
239
+ }
240
+ )
241
+
242
+ all_pairs = set(validation_net_count.keys())
243
+
244
+ return all_pairs, validation_net_count, all_leaves