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/recordstep.py ADDED
@@ -0,0 +1,181 @@
1
+ """RecordStep, the records a model matches over, and the verbs that build a plan.
2
+
3
+ A `RecordStep` is a step whose artifact is a table of records carrying an `id`.
4
+ `Source`, `Resolver` and `Transform` are the three kinds. On a `Source`, `id` is the
5
+ leaf. On a `Resolver`, `id` is the entity root. Every `RecordStep` chains the same
6
+ verbs (`select`, `clean`, `group`, `transform`, `dedupe`, `link`). A `Model` reads a
7
+ `RecordStep` and yields edges, not records.
8
+
9
+ `RecordStep` is not user-facing. Users hold a `Source`, a `Resolver` or a `Transform`
10
+ and call verbs on it. Each concrete kind supplies only how it materialises
11
+ (`_read_cache`) and which source rows it stands for (`_identifier_reads`).
12
+
13
+ A record step does not store `identifiers()`, the `(id, source, key, leaf)` mapping a
14
+ downstream resolver needs. Reshaping cannot change it, so it is read back from the
15
+ source leaves and the upstream resolver output instead.
16
+ """
17
+
18
+ from abc import abstractmethod
19
+ from typing import TYPE_CHECKING
20
+
21
+ import polars as pl
22
+
23
+ from matchlab.adapters import Adapter, Fingerprint
24
+ from matchlab.core.dataframes import (
25
+ DataFrameClass,
26
+ DataFrameType,
27
+ qualify,
28
+ to_dataframe,
29
+ )
30
+ from matchlab.core.sql import SQLExpression
31
+ from matchlab.steps import Step
32
+
33
+ if TYPE_CHECKING:
34
+ # Each of these modules imports this one. Only for annotations, so importing the
35
+ # real classes (rather than typing them `Any`) costs nothing at runtime.
36
+ from matchlab.models import Model
37
+ from matchlab.models.dedupers.base import Deduper, DeduperSettings
38
+ from matchlab.models.linkers.base import Linker, LinkerSettings
39
+ from matchlab.resolvers import Resolver
40
+ from matchlab.sources import Source
41
+ from matchlab.transformers import Transform, Transformer
42
+
43
+ # What one source's identifiers are read with, `(source_fp, source_name, resolver_fp)`,
44
+ # the arguments of `Adapter.read_identifiers`. Deduplicating these is deduplicating the
45
+ # queries, which is why they travel as a value rather than as a call.
46
+ IdentifierRead = tuple[Fingerprint | None, str, Fingerprint | None]
47
+
48
+
49
+ def build_record_step(
50
+ adapter: Adapter,
51
+ sources: "tuple[Source, ...]",
52
+ resolver: "Resolver | None",
53
+ ) -> pl.DataFrame:
54
+ """Assemble the `id` + qualified-columns table from stored extracts and identifiers.
55
+
56
+ Every source's extract is prefixed with the source name (`company` → `crn_company`)
57
+ and joined to its identifiers, so each row gains the `id` it belongs to. That is the
58
+ record's leaf when read directly, or the entity root when read through `resolver`.
59
+ Several sources are concatenated diagonally, each row carrying its own source's
60
+ columns and nulls for the rest. This is what both `Source` and `Resolver` read.
61
+ """
62
+ resolver_fp = resolver._fp if resolver is not None else None
63
+ identifiers = pl.concat(
64
+ [
65
+ adapter.read_identifiers(source._fp, source.name, resolver_fp)
66
+ for source in sources
67
+ ],
68
+ how="vertical",
69
+ )
70
+
71
+ extracts = [
72
+ adapter.read_source_extract(source._fp)
73
+ .select(pl.all().name.prefix(qualify(source.name)))
74
+ .with_columns(pl.lit(source.name).alias("source"))
75
+ .rename({source.qualified_key: "key"})
76
+ for source in sources
77
+ ]
78
+
79
+ return (
80
+ pl.concat(extracts, how="diagonal")
81
+ .join(identifiers.drop("leaf"), how="inner", on=("source", "key"))
82
+ .drop("source", "key")
83
+ )
84
+
85
+
86
+ class RecordStep(Step):
87
+ """A step whose artifact is a table of records a model matches over."""
88
+
89
+ # -- RecordStep contract -------------------------------------------------------
90
+
91
+ @abstractmethod
92
+ def _read_cache(self, adapter: Adapter) -> pl.DataFrame:
93
+ """Return this record step's records from wherever they are materialised."""
94
+ ...
95
+
96
+ @property
97
+ @abstractmethod
98
+ def _identifier_reads(self) -> tuple[IdentifierRead, ...]:
99
+ """The `Adapter.read_identifiers` arguments this record step's data comes from.
100
+
101
+ One per source, naming what is read rather than reading it. Two record steps
102
+ built from the same source and resolver read identical rows, even if they then
103
+ reshape those rows differently. A downstream resolver wants the *set* of these
104
+ reads across every record step feeding it, so naming them lets it ask once
105
+ instead of once per record step.
106
+ """
107
+ ...
108
+
109
+ def identifiers(self, adapter: Adapter) -> pl.DataFrame:
110
+ """Return `(id, source, key, leaf)` for every record this record step reads.
111
+
112
+ `id` is the resolver's entity root when reading through one, otherwise the
113
+ source leaf. This is the upstream resolver output a downstream resolver needs to
114
+ carry every reachable leaf forward, including records no model matched.
115
+ """
116
+ return pl.concat(
117
+ [adapter.read_identifiers(*read) for read in self._identifier_reads],
118
+ how="vertical",
119
+ )
120
+
121
+ def data(self, return_type: DataFrameType = DataFrameType.POLARS) -> DataFrameClass:
122
+ """Return this record step's records, collecting the plan first if needed."""
123
+ if not self.is_collected:
124
+ self.collect()
125
+ return to_dataframe(self._read_cache(self._require_adapter()), return_type)
126
+
127
+ # -- verbs ------------------------------------------------------------------------
128
+
129
+ def transform(
130
+ self,
131
+ transformer: "Transformer | type[Transformer] | str",
132
+ transformer_settings: dict | None = None,
133
+ ) -> "Transform":
134
+ """Reshape this record step with a transformer."""
135
+ from matchlab.transformers import Transform # noqa: PLC0415 - avoids a cycle
136
+
137
+ return Transform(self, transformer, transformer_settings)
138
+
139
+ def select(self, *columns: str) -> "Transform":
140
+ """Keep only the named columns, plus `id`."""
141
+ from matchlab.transformers import Select # noqa: PLC0415 - avoids a cycle
142
+
143
+ return self.transform(Select(*columns))
144
+
145
+ def clean(self, cleaning: dict[str, SQLExpression]) -> "Transform":
146
+ """Derive columns with DuckDB SQL, keeping the rest."""
147
+ from matchlab.transformers import Clean # noqa: PLC0415 - avoids a cycle
148
+
149
+ return self.transform(Clean(cleaning=cleaning))
150
+
151
+ def group(self, aggregates: dict[str, SQLExpression]) -> "Transform":
152
+ """Collapse each `id` to one row using aggregate SQL."""
153
+ from matchlab.transformers import Group # noqa: PLC0415 - avoids a cycle
154
+
155
+ return self.transform(Group(aggregates=aggregates))
156
+
157
+ def dedupe(
158
+ self,
159
+ model_class: "type[Deduper] | str",
160
+ model_settings: "DeduperSettings | dict",
161
+ ) -> "Model":
162
+ """Deduplicate this record step."""
163
+ from matchlab.models import Model # noqa: PLC0415 - avoids a cycle
164
+
165
+ return Model(left=self, model_class=model_class, model_settings=model_settings)
166
+
167
+ def link(
168
+ self,
169
+ other: "RecordStep",
170
+ model_class: "type[Linker] | str",
171
+ model_settings: "LinkerSettings | dict",
172
+ ) -> "Model":
173
+ """Link this record step to another. A `Source` is one, needing no wrapping."""
174
+ from matchlab.models import Model # noqa: PLC0415 - avoids a cycle
175
+
176
+ return Model(
177
+ left=self,
178
+ right=other,
179
+ model_class=model_class,
180
+ model_settings=model_settings,
181
+ )
@@ -0,0 +1,10 @@
1
+ """Resolver methodologies and the Resolver plan node."""
2
+
3
+ from matchlab.resolvers.components import Components, ComponentsSettings
4
+ from matchlab.resolvers.resolvers import Resolver
5
+
6
+ __all__ = (
7
+ "Components",
8
+ "ComponentsSettings",
9
+ "Resolver",
10
+ )
@@ -0,0 +1,63 @@
1
+ """Base classes for resolver methodologies."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import Mapping
5
+ from enum import StrEnum
6
+ from typing import ClassVar, Final
7
+
8
+ import polars as pl
9
+ import pyarrow as pa
10
+ from pydantic import BaseModel, ConfigDict
11
+
12
+ SCHEMA_CLUSTERS: Final[pa.Schema] = pa.schema(
13
+ [
14
+ ("parent_id", pa.uint64()),
15
+ ("child_id", pa.uint64()),
16
+ ]
17
+ )
18
+ """A resolver methodology's cluster assignments take this shape."""
19
+
20
+
21
+ class ResolverType(StrEnum):
22
+ """Enumeration of supported resolver methodology types.
23
+
24
+ This lives with `ResolverMethod`, not in `matchlab.specs`, because it is not part
25
+ of any spec. A `ResolverSpec` identifies a methodology only by its registered
26
+ `resolver_class`, so a `ResolverType` never reaches a fingerprint or a document.
27
+ It is just a class attribute methodologies use to declare which family they
28
+ belong to, and nothing more.
29
+ """
30
+
31
+ COMPONENTS = "components"
32
+
33
+
34
+ class ResolverSettings(BaseModel, ABC):
35
+ """Base settings type for resolver methodologies.
36
+
37
+ Settings that point at one of the resolver's inputs refer to it by **position**.
38
+ `Resolver` accepts the `Model` object at the API and translates it. Nothing here
39
+ holds a plan node. A methodology stays a pure function of edges and numbers.
40
+ """
41
+
42
+ model_config = ConfigDict(extra="forbid")
43
+
44
+
45
+ class ResolverMethod(BaseModel, ABC):
46
+ """Base class for resolver methodologies."""
47
+
48
+ resolver_type: ClassVar[ResolverType]
49
+ settings: ResolverSettings
50
+
51
+ @abstractmethod
52
+ def compute_clusters(self, model_edges: Mapping[int, pl.DataFrame]) -> pl.DataFrame:
53
+ """Compute cluster assignments from model edges.
54
+
55
+ Args:
56
+ model_edges: Input position to that model's edges, conforming to
57
+ SCHEMA_MODEL_EDGES. Positions index the resolver's inputs, in the
58
+ order they were given, and are what per-model settings key by.
59
+
60
+ Returns:
61
+ A Polars DataFrame which conforms to SCHEMA_CLUSTERS
62
+ """
63
+ ...
@@ -0,0 +1,66 @@
1
+ """Connected-components resolver methodology."""
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Annotated, ClassVar
5
+
6
+ import polars as pl
7
+ from pydantic import Field
8
+
9
+ from matchlab.core.dsu import DisjointSet
10
+ from matchlab.resolvers.base import (
11
+ SCHEMA_CLUSTERS,
12
+ ResolverMethod,
13
+ ResolverSettings,
14
+ ResolverType,
15
+ )
16
+
17
+
18
+ class ComponentsSettings(ResolverSettings):
19
+ """Settings for the Components resolver methodology."""
20
+
21
+ thresholds: dict[int, Annotated[float, Field(ge=0.0, le=1.0)]] = Field(
22
+ default_factory=dict,
23
+ description=(
24
+ "Minimum score for an edge to count, per input, keyed by the input's "
25
+ "position. Write these as `{model: 0.9}`. `Resolver` takes the model "
26
+ "object and works out the position."
27
+ ),
28
+ )
29
+
30
+
31
+ class Components(ResolverMethod):
32
+ """Resolver methodology that computes connected components.
33
+
34
+ A threshold defaults to 0.0 if not set.
35
+ """
36
+
37
+ resolver_type: ClassVar[ResolverType] = ResolverType.COMPONENTS
38
+ settings: ComponentsSettings
39
+
40
+ def compute_clusters( # noqa: D102
41
+ self, model_edges: Mapping[int, pl.DataFrame]
42
+ ) -> pl.DataFrame:
43
+ djs = DisjointSet[int]()
44
+
45
+ for position, edges in model_edges.items():
46
+ if edges.height == 0:
47
+ continue
48
+
49
+ threshold = self.settings.thresholds.get(position, 0.0)
50
+ filtered_edges = edges.filter(pl.col("score") >= threshold)
51
+
52
+ for left_id, right_id in filtered_edges.select(
53
+ "left_id", "right_id"
54
+ ).iter_rows():
55
+ djs.union(left_id, right_id)
56
+
57
+ rows: list[dict[str, int]] = []
58
+ for parent_id, component in enumerate(djs.get_components(), start=1):
59
+ rows.extend(
60
+ {"parent_id": parent_id, "child_id": node_id} for node_id in component
61
+ )
62
+
63
+ if not rows:
64
+ return pl.from_arrow(SCHEMA_CLUSTERS.empty_table())
65
+
66
+ return pl.DataFrame(rows).cast(pl.Schema(SCHEMA_CLUSTERS))