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/locations.py ADDED
@@ -0,0 +1,347 @@
1
+ """Interface to locations where source data is stored.
2
+
3
+ Location classes are registered by name.
4
+ """
5
+
6
+ from abc import ABC, abstractmethod
7
+ from collections.abc import Callable, Generator, Iterator
8
+ from contextlib import contextmanager
9
+ from enum import StrEnum
10
+ from typing import Any, ClassVar, Literal, overload
11
+
12
+ import polars as pl
13
+ import sqlglot
14
+ from adbc_driver_manager.dbapi import Connection as AdbcConnection
15
+ from pandas import DataFrame as PandasDataFrame
16
+ from polars import DataFrame as PolarsDataFrame
17
+ from pyarrow import Table as ArrowTable
18
+ from sqlalchemy import Connection, Engine
19
+ from sqlalchemy.exc import OperationalError
20
+ from sqlglot.errors import ParseError
21
+
22
+ from matchlab.core.dataframes import (
23
+ DataFrameClass,
24
+ DataFrameType,
25
+ sql_to_df,
26
+ )
27
+ from matchlab.core.exceptions import ExtractTransformError
28
+ from matchlab.core.logging import logger
29
+ from matchlab.core.sql import SQLQuery
30
+
31
+
32
+ class ClientType(StrEnum):
33
+ """The client libraries a relational location can be driven by."""
34
+
35
+ SQLALCHEMY = "sqlalchemy"
36
+ ADBC = "adbc"
37
+
38
+
39
+ _LOCATION_CLASSES: dict[str, "type[Location]"] = {}
40
+
41
+
42
+ def add_location_class(location_class: "type[Location]") -> None:
43
+ """Register a custom location so it can be named in a plan document."""
44
+ if not issubclass(location_class, Location):
45
+ raise ValueError("The argument is not a subclass of Location.")
46
+ _LOCATION_CLASSES[location_class.__name__] = location_class
47
+
48
+
49
+ def build_location(location_class: str, name: str, client: Any) -> "Location": # noqa: ANN401
50
+ """Build a registered location class, bound to a name and a client.
51
+
52
+ How `matchlab.document` rebuilds the locations a plan reads. Both arguments come
53
+ from outside any spec: the class because a `SourceSpec` deliberately says
54
+ nothing about where its rows came from, the client because a document carries no
55
+ credentials.
56
+
57
+ Raises:
58
+ ValueError: If no location class of that name is registered here.
59
+ """
60
+ if location_class not in _LOCATION_CLASSES:
61
+ raise ValueError(
62
+ f"No location class named '{location_class}' is registered. Register it "
63
+ "with `add_location_class` before loading a document that names it."
64
+ )
65
+ return _LOCATION_CLASSES[location_class](name=name, client=client)
66
+
67
+
68
+ class Location(ABC):
69
+ """A named place data is read from, bound to the client that reads it."""
70
+
71
+ client_classes: ClassVar[tuple[type, ...]]
72
+ """The client types this location can be driven by, checked on construction."""
73
+
74
+ def __init__(self, name: str, client: Any) -> None: # noqa: ANN401
75
+ """Initialise a location.
76
+
77
+ Args:
78
+ name: How a plan refers to this location. A handle for the client, not a
79
+ setting. It never reaches a fingerprint.
80
+ client: The already-configured client to read through. Required: a location
81
+ without one can answer nothing, not even which SQL dialect it speaks.
82
+
83
+ Raises:
84
+ ValueError: If the client is not a type this location can use.
85
+ """
86
+ if not isinstance(client, self.client_classes):
87
+ raise ValueError(
88
+ f"{type(client).__name__} is not a valid client for "
89
+ f"{type(self).__name__}. Expected one of: "
90
+ f"{', '.join(accepted.__name__ for accepted in self.client_classes)}."
91
+ )
92
+ self.name = name
93
+ self._client = client
94
+
95
+ def __str__(self) -> str:
96
+ """Identify the location by class and name."""
97
+ return f"{type(self).__name__} '{self.name}'"
98
+
99
+ @property
100
+ def client(self) -> Any: # noqa: ANN401
101
+ """The client this location reads through. Set once, at construction."""
102
+ return self._client
103
+
104
+ @abstractmethod
105
+ def connect(self) -> bool:
106
+ """Check the location can actually be reached."""
107
+ ...
108
+
109
+ @abstractmethod
110
+ def validate_extract_transform(self, extract_transform: SQLQuery) -> None:
111
+ """Validate ET logic against this location's query language.
112
+
113
+ Raises:
114
+ ExtractTransformError: If the ET logic is invalid.
115
+ """
116
+ ...
117
+
118
+ @abstractmethod
119
+ def execute(
120
+ self,
121
+ extract_transform: SQLQuery,
122
+ batch_size: int | None = None,
123
+ rename: dict[str, str] | Callable | None = None,
124
+ return_type: DataFrameType = DataFrameType.POLARS,
125
+ keys: tuple[str, list[str]] | None = None,
126
+ schema_overrides: dict[str, pl.DataType] | None = None,
127
+ ) -> Iterator[DataFrameClass]:
128
+ """Execute ET logic against location and return batches.
129
+
130
+ Args:
131
+ extract_transform: The ET logic to execute.
132
+ batch_size: The size used for internal batching. Overrides environment
133
+ variable if set.
134
+ rename: Renaming to apply after the ET logic is executed.
135
+
136
+ * If a dictionary is provided, it will be used to rename the columns.
137
+ * If a callable is provided, it will take the old name as input and
138
+ return the new name.
139
+ return_type: The type of data to return. Defaults to "polars".
140
+ keys: Rule to only retrieve rows by specific keys.
141
+ The key of the dictionary is a field name on which to filter.
142
+ Filters source entries where the key field is in the dict values.
143
+ schema_overrides: Types to force on the columns that come back, rather
144
+ than letting the client infer them.
145
+ """
146
+ ...
147
+
148
+
149
+ class RelationalDBLocation(Location):
150
+ """A location for a relational database."""
151
+
152
+ client_classes: ClassVar[tuple[type, ...]] = (Engine, AdbcConnection)
153
+
154
+ @property
155
+ def client(self) -> Engine | AdbcConnection:
156
+ """The SQLAlchemy engine or ADBC connection this location reads through."""
157
+ return self._client
158
+
159
+ @property
160
+ def client_type(self) -> ClientType:
161
+ """Which client type this location was built with.
162
+
163
+ One `isinstance` decides it, because `client_classes` has already rejected
164
+ anything that is neither an `Engine` nor an ADBC connection.
165
+ """
166
+ return (
167
+ ClientType.SQLALCHEMY
168
+ if isinstance(self.client, Engine)
169
+ else ClientType.ADBC
170
+ )
171
+
172
+ @contextmanager
173
+ def _get_connection(self) -> Generator[Connection | AdbcConnection, None, None]:
174
+ """Get a database connection, and close or release it properly afterwards."""
175
+ if self.client_type == ClientType.ADBC:
176
+ # Cloning avoids memory corruption in the ADBC driver, which happens if
177
+ # a single connection is reused for multiple queries.
178
+ with self.client.adbc_clone() as connection:
179
+ yield connection
180
+ else: # SQLAlchemy Engine
181
+ connection = self.client.connect()
182
+ try:
183
+ yield connection
184
+ finally:
185
+ connection.close()
186
+
187
+ def connect(self) -> bool: # noqa: D102
188
+ try:
189
+ with self._get_connection() as conn:
190
+ _ = sql_to_df(stmt="select 1", connection=conn)
191
+ return True
192
+ except OperationalError:
193
+ return False
194
+
195
+ @property
196
+ def _sqlglot_dialect(self) -> str | None:
197
+ """The dialect to parse extract/transform SQL as, if we recognise it."""
198
+ if self.client_type == ClientType.SQLALCHEMY:
199
+ client_dialect = self.client.dialect.name
200
+ else:
201
+ vendor = self.client.adbc_get_info().get("vendor_name")
202
+ client_dialect = vendor.lower() if vendor else None
203
+
204
+ match client_dialect:
205
+ case "postgresql":
206
+ return "postgres"
207
+ case "sqlite":
208
+ return "sqlite"
209
+ case _:
210
+ logger.warning("Could not validate specific dialect.")
211
+ return None
212
+
213
+ def validate_extract_transform(self, extract_transform: SQLQuery) -> None:
214
+ """Check that the SQL statement only contains a single data-extracting command.
215
+
216
+ This does not fully sanitise the SQL statement. Validation only guards
217
+ against accidental mistakes, not malicious actors. Only run indexing using
218
+ sources you trust and have read, with least-privilege credentials.
219
+
220
+ Args:
221
+ extract_transform: The SQL statement to validate.
222
+
223
+ Raises:
224
+ ParseError: If the SQL statement cannot be parsed.
225
+ ExtractTransformError: If validation requirements are not met.
226
+ """
227
+ if not extract_transform.strip():
228
+ raise ExtractTransformError(
229
+ "SQL statement is empty or only contains whitespace."
230
+ )
231
+
232
+ try:
233
+ expressions = sqlglot.parse(
234
+ extract_transform, dialect=self._sqlglot_dialect
235
+ )
236
+ except ParseError as e:
237
+ raise ExtractTransformError("SQL statement could not be parsed.") from e
238
+
239
+ if len(expressions) > 1:
240
+ raise ExtractTransformError("SQL statement contains multiple commands.")
241
+
242
+ if not expressions:
243
+ raise ExtractTransformError(
244
+ "SQL statement does not contain any valid expressions."
245
+ )
246
+
247
+ expr = expressions[0]
248
+
249
+ if not isinstance(expr, sqlglot.expressions.Select | sqlglot.expressions.Union):
250
+ raise ExtractTransformError(
251
+ "SQL statement must start with a SELECT or WITH command."
252
+ )
253
+
254
+ forbidden = (
255
+ sqlglot.expressions.DDL,
256
+ sqlglot.expressions.DML,
257
+ sqlglot.expressions.Into,
258
+ )
259
+
260
+ if len(list(expr.find_all(forbidden))) > 0:
261
+ raise ExtractTransformError(
262
+ "SQL statement must not contain DDL or DML commands."
263
+ )
264
+
265
+ @overload
266
+ def execute(
267
+ self,
268
+ extract_transform: SQLQuery,
269
+ batch_size: int | None = None,
270
+ rename: dict[str, str] | Callable | None = None,
271
+ return_type: Literal[DataFrameType.POLARS] = ...,
272
+ keys: tuple[str, list[str]] | None = None,
273
+ schema_overrides: dict[str, pl.DataType] | None = None,
274
+ ) -> Generator[PolarsDataFrame, None, None]: ...
275
+
276
+ @overload
277
+ def execute(
278
+ self,
279
+ extract_transform: SQLQuery,
280
+ batch_size: int | None = None,
281
+ rename: dict[str, str] | Callable | None = None,
282
+ return_type: Literal[DataFrameType.PANDAS] = ...,
283
+ keys: tuple[str, list[str]] | None = None,
284
+ schema_overrides: dict[str, pl.DataType] | None = None,
285
+ ) -> Generator[PandasDataFrame, None, None]: ...
286
+
287
+ @overload
288
+ def execute(
289
+ self,
290
+ extract_transform: SQLQuery,
291
+ batch_size: int | None = None,
292
+ rename: dict[str, str] | Callable | None = None,
293
+ return_type: Literal[DataFrameType.ARROW] = ...,
294
+ keys: tuple[str, list[str]] | None = None,
295
+ schema_overrides: dict[str, pl.DataType] | None = None,
296
+ ) -> Generator[ArrowTable, None, None]: ...
297
+
298
+ def execute( # noqa: D102
299
+ self,
300
+ extract_transform: SQLQuery,
301
+ batch_size: int | None = None,
302
+ rename: dict[str, str] | Callable | None = None,
303
+ return_type: DataFrameType = DataFrameType.POLARS,
304
+ keys: tuple[str, list[str]] | None = None,
305
+ schema_overrides: dict[str, pl.DataType] | None = None,
306
+ ) -> Generator[DataFrameClass, None, None]:
307
+ # Strip semicolon, as it can block extended query protocol
308
+ # and slow some engines down
309
+ extract_transform = extract_transform.replace(";", "")
310
+
311
+ # We always work in batches to simplify higher level logic
312
+ batch_size: int = batch_size or 10_000
313
+
314
+ # TODO(adbc-batching): this size is honoured for SQLAlchemy clients and
315
+ # silently ignored for ADBC ones. Polars registers ADBC with
316
+ # `exact_batch_size: False` (see `polars/io/database/_arrow_registry.py`), so
317
+ # it calls `fetch_record_batch()` with no size and the driver chunks however
318
+ # it likes. In-process drivers return the whole result in one batch.
319
+ # `Source.sample(n)` takes the first batch and so can return the entire
320
+ # source, and `_read_warehouse`'s stream-to-Parquet stops bounding memory.
321
+ # Fix by slicing the record-batch stream to `batch_size` in `sql_to_df`.
322
+
323
+ with self._get_connection() as conn:
324
+ if keys:
325
+ key_field, filter_values = keys
326
+ quoted_key_values = [f"'{key_val}'" for key_val in filter_values]
327
+ # Only filter original SQL if keys are provided
328
+ if quoted_key_values:
329
+ comma_separated_values = ", ".join(quoted_key_values)
330
+ # This "IN" expression is a SQL standard, though that is hard to
331
+ # prove, since the standard is behind a paywall.
332
+ extract_transform = (
333
+ f"select * from ({extract_transform}) as sub "
334
+ f"where {key_field} in ({comma_separated_values})"
335
+ )
336
+ yield from sql_to_df(
337
+ stmt=extract_transform,
338
+ schema_overrides=schema_overrides,
339
+ connection=conn,
340
+ rename=rename,
341
+ batch_size=batch_size,
342
+ return_batches=True,
343
+ return_type=return_type,
344
+ )
345
+
346
+
347
+ add_location_class(RelationalDBLocation)
@@ -0,0 +1,10 @@
1
+ """Deduplication and linking methodologies."""
2
+
3
+ from matchlab.models.comparison import comparison
4
+ from matchlab.models.models import Model, add_model_class
5
+
6
+ __all__ = (
7
+ "comparison",
8
+ "Model",
9
+ "add_model_class",
10
+ )
@@ -0,0 +1,67 @@
1
+ """Functions to compare fields in different sources."""
2
+
3
+ import sqlglot.expressions as exp
4
+ from sqlglot import parse_one
5
+ from sqlglot.errors import ParseError
6
+
7
+ from matchlab.core.logging import logger
8
+ from matchlab.core.sql import SQLCondition
9
+
10
+
11
+ def comparison(sql_condition: SQLCondition, dialect: str = "duckdb") -> SQLCondition:
12
+ """Validate a SQL WHERE-clause condition and recompile it for `dialect`.
13
+
14
+ Every column must be qualified with the alias `l` or `r`, the two sides a
15
+ [`Model`][matchlab.models.Model] compares. Raises `ParseError` if a column isn't
16
+ qualified this way, if the condition isn't a valid boolean expression, or if it
17
+ doesn't reference both sides.
18
+
19
+ `dialect` only changes how the validated condition renders back to SQL, not how
20
+ it's parsed, so `sql_condition` must already be syntax `sqlglot` can parse
21
+ without help.
22
+
23
+ Logs a warning for `OR` conditions, since DuckDB can't use an index to prune them.
24
+ """
25
+ parsed_sql = parse_one(sql_condition)
26
+
27
+ for node in parsed_sql.walk():
28
+ # `walk()` yields each expression directly (older sqlglot yielded a
29
+ # `(node, parent, key)` tuple, which is why this once read `node[0]`).
30
+ if not isinstance(
31
+ node,
32
+ exp.Connector | exp.Predicate | exp.Condition | exp.Identifier,
33
+ ):
34
+ raise ParseError(
35
+ f"Must be valid WHERE clause statements. Found {type(node)}"
36
+ )
37
+
38
+ if isinstance(node, exp.Or):
39
+ logger.warning(
40
+ "Or conditions are computationally expensive. Expect slow performance."
41
+ )
42
+
43
+ left = False
44
+ right = False
45
+ for column in parsed_sql.find_all(exp.Column):
46
+ if column.table == "l":
47
+ left = True
48
+ elif column.table == "r":
49
+ right = True
50
+ elif column.table == "":
51
+ raise ParseError(
52
+ "Columns must be explicitly declared as one of table "
53
+ '"l" or "r".\n\n'
54
+ f"No table declared for column {column.name}."
55
+ )
56
+ else:
57
+ raise ParseError(
58
+ "Columns must be explicitly declared as one of table "
59
+ '"l" or "r".\n\n'
60
+ f"Found column {column.table}."
61
+ )
62
+ if not (left and right):
63
+ raise ParseError('Conditions must reference both "l" and "r".')
64
+
65
+ pg_sql = parsed_sql.sql(dialect=dialect)
66
+
67
+ return pg_sql
@@ -0,0 +1,5 @@
1
+ """Deduplication methodologies."""
2
+
3
+ from matchlab.models.dedupers.naive import NaiveDeduper
4
+
5
+ __all__ = ("NaiveDeduper",)
@@ -0,0 +1,38 @@
1
+ """Base class for deduplication methodologies."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Literal
5
+
6
+ import polars as pl
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class DeduperSettings(BaseModel):
11
+ """Settings shared by every Deduper methodology."""
12
+
13
+ id: Literal["id"] = Field(
14
+ default="id", description="The unique ID field in the data to dedupe"
15
+ )
16
+
17
+
18
+ class Deduper(BaseModel, ABC):
19
+ """A methodology that finds candidate duplicate pairs within one record step.
20
+
21
+ A `Model` step calls `prepare()` once, then `dedupe()`, each time it collects. Put
22
+ one-off setup in `prepare()` instead, for example fitting a model over the whole
23
+ dataset, so it doesn't repeat on every call to `dedupe()`. `dedupe()` must return
24
+ a table with `left_id`, `right_id`, and `score` columns. `normalise_model_scores`
25
+ casts that table to `SCHEMA_MODEL_EDGES`.
26
+ """
27
+
28
+ settings: DeduperSettings
29
+
30
+ @abstractmethod
31
+ def prepare(self, data: pl.DataFrame) -> None:
32
+ """Run once before `dedupe()`, for setup that shouldn't repeat per call."""
33
+ ...
34
+
35
+ @abstractmethod
36
+ def dedupe(self, data: pl.DataFrame) -> pl.DataFrame:
37
+ """Score candidate duplicate pairs within `data`."""
38
+ ...
@@ -0,0 +1,77 @@
1
+ """A deduplication methodology based on a deterministic set of conditions."""
2
+
3
+ import duckdb
4
+ import polars as pl
5
+ from pydantic import Field
6
+
7
+ from matchlab.models.dedupers.base import Deduper, DeduperSettings
8
+
9
+
10
+ class NaiveSettings(DeduperSettings):
11
+ """Settings for `NaiveDeduper`."""
12
+
13
+ unique_fields: list[str] = Field(
14
+ description="A list of fields that will form a unique, deduplicated record"
15
+ )
16
+
17
+
18
+ class NaiveDeduper(Deduper):
19
+ """Groups records that match exactly on every field in `unique_fields`."""
20
+
21
+ settings: NaiveSettings
22
+
23
+ _id_dtype: pl.DataType
24
+
25
+ def prepare(self, data: pl.DataFrame) -> None:
26
+ """No preparation needed."""
27
+ pass
28
+
29
+ def dedupe(self, data: pl.DataFrame) -> pl.DataFrame:
30
+ """Deduplicate the dataframe. Every match scores 1.0."""
31
+ self._id_dtype = data[self.settings.id].dtype
32
+
33
+ df = data.clone()
34
+
35
+ join_clause = []
36
+ for field in self.settings.unique_fields:
37
+ join_clause.append(f"l.{field} = r.{field}")
38
+ join_clause_compiled = " and ".join(join_clause)
39
+
40
+ # Add a row index so a genuine duplicate row (identical values, different
41
+ # index) isn't discarded as a self-match.
42
+ df = df.with_row_index("_unique_e4003b")
43
+
44
+ # If `id` isn't unique per row (for example, after unnesting an array of
45
+ # keys), two different rows can still share the same left_id/right_id value.
46
+ # Drop those pairs too.
47
+ sql = f"""
48
+ select distinct on (list_sort([raw.left_id, raw.right_id]))
49
+ raw.left_id,
50
+ raw.right_id,
51
+ 1.0 as score
52
+ from (
53
+ select
54
+ l.{self.settings.id} as left_id,
55
+ r.{self.settings.id} as right_id
56
+ from
57
+ df l
58
+ inner join df r on
59
+ (
60
+ {join_clause_compiled}
61
+ ) and
62
+ l._unique_e4003b != r._unique_e4003b
63
+ ) raw
64
+ where raw.left_id != raw.right_id;
65
+ """
66
+
67
+ return (
68
+ duckdb.sql(sql)
69
+ .pl()
70
+ .with_columns(
71
+ [
72
+ pl.col("left_id").cast(self._id_dtype),
73
+ pl.col("right_id").cast(self._id_dtype),
74
+ pl.col("score").cast(pl.Float32),
75
+ ]
76
+ )
77
+ )
@@ -0,0 +1,9 @@
1
+ """Linking methodologies."""
2
+
3
+ from matchlab.models.linkers.deterministic import DeterministicLinker
4
+ from matchlab.models.linkers.splinklinker import SplinkLinker
5
+ from matchlab.models.linkers.weighteddeterministic import (
6
+ WeightedDeterministicLinker,
7
+ )
8
+
9
+ __all__ = ("DeterministicLinker", "WeightedDeterministicLinker", "SplinkLinker")
@@ -0,0 +1,41 @@
1
+ """Base class for linkers."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Literal
5
+
6
+ import polars as pl
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class LinkerSettings(BaseModel):
11
+ """Settings shared by every Linker methodology."""
12
+
13
+ left_id: Literal["id"] = Field(
14
+ default="id", description="The unique ID field in the left data"
15
+ )
16
+ right_id: Literal["id"] = Field(
17
+ default="id", description="The unique ID field in the right data"
18
+ )
19
+
20
+
21
+ class Linker(BaseModel, ABC):
22
+ """A methodology that finds candidate matches between two record steps.
23
+
24
+ A `Model` step calls `prepare()` once, then `link()`, each time it collects. Put
25
+ one-off setup in `prepare()` instead, for example fitting a model over both
26
+ datasets, so it doesn't repeat on every call to `link()`. `link()` must return a
27
+ table with `left_id`, `right_id`, and `score` columns. `normalise_model_scores`
28
+ casts that table to `SCHEMA_MODEL_EDGES`.
29
+ """
30
+
31
+ settings: LinkerSettings
32
+
33
+ @abstractmethod
34
+ def prepare(self, left: pl.DataFrame, right: pl.DataFrame) -> None:
35
+ """Run once before `link()`, for setup that shouldn't repeat per call."""
36
+ ...
37
+
38
+ @abstractmethod
39
+ def link(self, left: pl.DataFrame, right: pl.DataFrame) -> pl.DataFrame:
40
+ """Score candidate matches between `left` and `right`."""
41
+ ...