matchlab 0.1.0.dev2__tar.gz → 0.1.0.dev4__tar.gz

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 (207) hide show
  1. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/PKG-INFO +1 -1
  2. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/guide/serialise.md +8 -14
  3. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/document.py +44 -38
  4. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/models/linkers/splinklinker.py +37 -6
  5. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab.egg-info/PKG-INFO +1 -1
  6. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab.egg-info/SOURCES.txt +1 -0
  7. matchlab-0.1.0.dev4/src/matchlab.egg-info/scm_version.json +8 -0
  8. matchlab-0.1.0.dev4/test/models/test_linkers_splink.py +42 -0
  9. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/plan/test_plan.py +8 -3
  10. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/test_document.py +67 -46
  11. matchlab-0.1.0.dev2/src/matchlab.egg-info/scm_version.json +0 -8
  12. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/.github/actions/setup-toolchain/action.yml +0 -0
  13. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/.github/dependabot.yml +0 -0
  14. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/.github/pull_request_template.md +0 -0
  15. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/.github/skills/lucid-docs/README.md +0 -0
  16. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/.github/skills/lucid-docs/SKILL.md +0 -0
  17. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/.github/skills/lucid-docs/readability.py +0 -0
  18. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/.github/workflows/cd.yml +0 -0
  19. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/.github/workflows/ci.yml +0 -0
  20. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/.github/workflows/codeql.yml +0 -0
  21. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/.gitignore +0 -0
  22. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/.pre-commit-config.yaml +0 -0
  23. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/.vscode/launch.json +0 -0
  24. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/.vscode/settings.json +0 -0
  25. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/CODEOWNERS +0 -0
  26. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/LICENSE +0 -0
  27. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/README.md +0 -0
  28. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/benchmarks/README.md +0 -0
  29. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/benchmarks/__init__.py +0 -0
  30. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/benchmarks/__main__.py +0 -0
  31. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/benchmarks/cases.py +0 -0
  32. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/benchmarks/data.py +0 -0
  33. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/benchmarks/measure.py +0 -0
  34. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/benchmarks/measurements.py +0 -0
  35. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/benchmarks/report.py +0 -0
  36. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/benchmarks/results/.gitkeep +0 -0
  37. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/benchmarks/runner.py +0 -0
  38. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/benchmarks/topologies.py +0 -0
  39. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/api/adapters.md +0 -0
  40. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/api/core/exceptions.md +0 -0
  41. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/api/core/hash.md +0 -0
  42. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/api/core/resolver_output.md +0 -0
  43. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/api/core/schemas.md +0 -0
  44. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/api/core/transform.md +0 -0
  45. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/api/document.md +0 -0
  46. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/api/eval-judgements.md +0 -0
  47. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/api/eval-metrics.md +0 -0
  48. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/api/eval-sampling.md +0 -0
  49. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/api/eval-tui.md +0 -0
  50. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/api/lineage.md +0 -0
  51. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/api/locations.md +0 -0
  52. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/api/models.md +0 -0
  53. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/api/progress.md +0 -0
  54. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/api/recordstep.md +0 -0
  55. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/api/resolvers.md +0 -0
  56. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/api/resources.md +0 -0
  57. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/api/sources.md +0 -0
  58. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/api/specs.md +0 -0
  59. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/api/steps.md +0 -0
  60. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/api/testkit.md +0 -0
  61. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/api/transformers.md +0 -0
  62. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/assets/matchbox-icon-dark.png +0 -0
  63. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/assets/matchbox-icon.svg +0 -0
  64. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/assets/matchbox-logo-dark.svg +0 -0
  65. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/assets/matchbox-logo-light.svg +0 -0
  66. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/contributing.md +0 -0
  67. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/glossary.md +0 -0
  68. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/guide/build-a-plan.md +0 -0
  69. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/guide/evaluate.md +0 -0
  70. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/guide/install.md +0 -0
  71. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/guide/query.md +0 -0
  72. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/index.md +0 -0
  73. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/migration/matchbox-to-matchlab.md +0 -0
  74. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/stylesheets/extra.css +0 -0
  75. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/use-cases.md +0 -0
  76. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/docs/why-matchlab.md +0 -0
  77. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/justfile +0 -0
  78. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/mkdocs.yml +0 -0
  79. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/personal-data-exclusions.txt +0 -0
  80. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/pyproject.toml +0 -0
  81. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/scripts/test_eval.py +0 -0
  82. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/setup.cfg +0 -0
  83. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/__init__.py +0 -0
  84. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/adapters/__init__.py +0 -0
  85. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/adapters/base.py +0 -0
  86. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/adapters/duckdb.py +0 -0
  87. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/cli.py +0 -0
  88. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/core/__init__.py +0 -0
  89. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/core/dataframes.py +0 -0
  90. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/core/dsu.py +0 -0
  91. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/core/exceptions.py +0 -0
  92. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/core/hash.py +0 -0
  93. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/core/kinds.py +0 -0
  94. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/core/logging.py +0 -0
  95. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/core/resolver_output.py +0 -0
  96. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/core/schemas.py +0 -0
  97. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/core/sql.py +0 -0
  98. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/eval/__init__.py +0 -0
  99. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/eval/judgements.py +0 -0
  100. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/eval/metrics.py +0 -0
  101. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/eval/samples.py +0 -0
  102. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/eval/tui/__init__.py +0 -0
  103. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/eval/tui/app.py +0 -0
  104. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/eval/tui/modals.py +0 -0
  105. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/eval/tui/styles.tcss +0 -0
  106. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/eval/tui/widgets/__init__.py +0 -0
  107. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/eval/tui/widgets/assignment.py +0 -0
  108. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/eval/tui/widgets/styling.py +0 -0
  109. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/eval/tui/widgets/table.py +0 -0
  110. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/lineage.py +0 -0
  111. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/models/__init__.py +0 -0
  112. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/models/comparison.py +0 -0
  113. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/models/dedupers/__init__.py +0 -0
  114. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/models/dedupers/base.py +0 -0
  115. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/models/dedupers/naive.py +0 -0
  116. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/models/linkers/__init__.py +0 -0
  117. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/models/linkers/base.py +0 -0
  118. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/models/linkers/deterministic.py +0 -0
  119. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/models/linkers/weighteddeterministic.py +0 -0
  120. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/models/models.py +0 -0
  121. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/progress.py +0 -0
  122. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/recordstep.py +0 -0
  123. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/resolvers/__init__.py +0 -0
  124. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/resolvers/base.py +0 -0
  125. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/resolvers/components.py +0 -0
  126. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/resolvers/resolvers.py +0 -0
  127. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/resources.py +0 -0
  128. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/sources/__init__.py +0 -0
  129. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/sources/base.py +0 -0
  130. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/sources/dataframe.py +0 -0
  131. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/sources/relational.py +0 -0
  132. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/sources/sources.py +0 -0
  133. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/specs.py +0 -0
  134. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/steps.py +0 -0
  135. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/testkit/__init__.py +0 -0
  136. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/testkit/_generate.py +0 -0
  137. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/testkit/compare.py +0 -0
  138. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/testkit/entities.py +0 -0
  139. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/testkit/features.py +0 -0
  140. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/testkit/linked.py +0 -0
  141. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/testkit/matchers.py +0 -0
  142. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/testkit/models.py +0 -0
  143. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/testkit/sources.py +0 -0
  144. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/transformers/__init__.py +0 -0
  145. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/transformers/base.py +0 -0
  146. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/transformers/clean.py +0 -0
  147. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/transformers/explode.py +0 -0
  148. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/transformers/group.py +0 -0
  149. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/transformers/select.py +0 -0
  150. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab/transformers/transform.py +0 -0
  151. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab.egg-info/dependency_links.txt +0 -0
  152. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab.egg-info/entry_points.txt +0 -0
  153. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab.egg-info/requires.txt +0 -0
  154. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab.egg-info/scm_file_list.json +0 -0
  155. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/src/matchlab.egg-info/top_level.txt +0 -0
  156. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/README.md +0 -0
  157. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/__init__.py +0 -0
  158. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/adapters/__init__.py +0 -0
  159. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/adapters/conftest.py +0 -0
  160. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/adapters/test_base.py +0 -0
  161. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/adapters/test_contract.py +0 -0
  162. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/adapters/test_duckdb.py +0 -0
  163. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/conftest.py +0 -0
  164. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/core/__init__.py +0 -0
  165. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/core/test_dsu.py +0 -0
  166. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/core/test_eval.py +0 -0
  167. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/core/test_hash.py +0 -0
  168. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/core/test_resolver_output.py +0 -0
  169. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/eval/__init__.py +0 -0
  170. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/eval/test_samples.py +0 -0
  171. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/eval/tui/__init__.py +0 -0
  172. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/eval/tui/test_app.py +0 -0
  173. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/eval/tui/test_widgets.py +0 -0
  174. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/models/__init__.py +0 -0
  175. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/models/test_comparison.py +0 -0
  176. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/models/test_dedupers_deterministic.py +0 -0
  177. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/models/test_linkers_deterministic.py +0 -0
  178. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/models/test_linkers_probabilistic.py +0 -0
  179. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/models/test_models.py +0 -0
  180. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/plan/__init__.py +0 -0
  181. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/plan/test_ground_truth.py +0 -0
  182. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/plan/test_steps.py +0 -0
  183. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/resolvers/__init__.py +0 -0
  184. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/resolvers/test_components.py +0 -0
  185. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/resolvers/test_resolvers.py +0 -0
  186. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/sources/__init__.py +0 -0
  187. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/sources/conftest.py +0 -0
  188. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/sources/test_locations.py +0 -0
  189. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/test_benchmarks.py +0 -0
  190. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/test_cli.py +0 -0
  191. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/test_lineage.py +0 -0
  192. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/test_progress.py +0 -0
  193. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/test_resources.py +0 -0
  194. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/testkit/__init__.py +0 -0
  195. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/testkit/test_entities.py +0 -0
  196. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/testkit/test_linked.py +0 -0
  197. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/testkit/test_models.py +0 -0
  198. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/testkit/test_scores.py +0 -0
  199. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/testkit/test_sources.py +0 -0
  200. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/transformers/__init__.py +0 -0
  201. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/transformers/test_clean.py +0 -0
  202. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/transformers/test_explode.py +0 -0
  203. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/transformers/test_group.py +0 -0
  204. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/transformers/test_select.py +0 -0
  205. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/test/transformers/test_transform.py +0 -0
  206. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/trufflehog-exclude.txt +0 -0
  207. {matchlab-0.1.0.dev2 → matchlab-0.1.0.dev4}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matchlab
3
- Version: 0.1.0.dev2
3
+ Version: 0.1.0.dev4
4
4
  Summary: A local-first library for building, running and evaluating entity resolution pipelines.
5
5
  Author: Department for Business and Trade
6
6
  Project-URL: Documentation, https://uktrade.github.io/matchlab/
@@ -9,11 +9,10 @@ from pathlib import Path
9
9
 
10
10
  import matchlab as mb
11
11
 
12
- document = mb.dump(entities)
13
- Path("plan.json").write_text(document.model_dump_json(indent=2))
12
+ Path("plan.json").write_text(mb.dump(entities))
14
13
  ```
15
14
 
16
- `dump()` takes the apex of the plan and walks upstream, covering exactly what `collect()` would run.
15
+ `dump()` takes the apex of the plan and walks upstream, covering exactly what `collect()` would run. It hands back JSON, so where the document goes is your call: a file, a column, an object store.
17
16
 
18
17
  ## What a document holds
19
18
 
@@ -136,22 +135,17 @@ name rather than the value, and no credential is serialised.
136
135
 
137
136
  ## Loading
138
137
 
139
- `load()` rebuilds the plan and returns its apex. Nothing is collected.
138
+ `load()` rebuilds the plan and returns its apex. Nothing is collected. Hand it the JSON:
140
139
 
141
140
  ```python
142
- document = mb.PlanDocument.model_validate_json(Path("plan.json").read_text())
143
-
144
- entities = mb.load(document, resources={"warehouse": create_engine("postgresql://...")})
141
+ entities = mb.load(
142
+ Path("plan.json").read_text(),
143
+ resources={"warehouse": create_engine("postgresql://...")},
144
+ )
145
145
  entities.collect()
146
146
  ```
147
147
 
148
- `resources` is keyed by resource name, not by settings field. Ask the document what it wants:
149
-
150
- ```python
151
- document.required_resources() # {'warehouse'}
152
- ```
153
-
154
- Miss one out and `load()` fails at once, rather than several steps into a collect.
148
+ `resources` is keyed by resource name. Miss any out and `load()` says so before rebuilding anything.
155
149
 
156
150
  ## Fingerprints transfer
157
151
 
@@ -158,15 +158,19 @@ class PlanDocument(BaseModel):
158
158
  return {name for node in self.steps for name in node.resources.values()}
159
159
 
160
160
 
161
- def dump(root: Step) -> PlanDocument:
161
+ def dump(root: Step, *, indent: int | None = None) -> str:
162
162
  """Describe `root` and everything it reads as a portable document.
163
163
 
164
+ JSON rather than an object, because a document exists to be put somewhere: a
165
+ column, an object store, a request body, a file.
166
+
164
167
  Args:
165
168
  root: The apex of the plan. Only what is reachable upstream of it is included,
166
169
  exactly as `collect()` would run.
170
+ indent: How the JSON is laid out. `None`, the default, is compact.
167
171
 
168
172
  Returns:
169
- The document. It holds no resource, no credentials and no data.
173
+ The document, as JSON.
170
174
 
171
175
  Raises:
172
176
  ResourceError: If a resource was passed without a name, so there is nothing for
@@ -174,23 +178,9 @@ def dump(root: Step) -> PlanDocument:
174
178
  earlier, when the plan is built.
175
179
  """
176
180
  ordered = walk(root)
177
- _check_resources(ordered)
178
- position = {id(step): index for index, step in enumerate(ordered)}
179
- return PlanDocument(
180
- steps=tuple(
181
- _node(step, tuple(position[id(parent)] for parent in step.parents))
182
- for step in ordered
183
- )
184
- )
185
-
186
-
187
- def _check_resources(steps: list[Step]) -> None:
188
- """Reject a plan whose resources have no names to record.
189
181
 
190
- Raises:
191
- ResourceError: If a resource is unnamed.
192
- """
193
- for position, step in enumerate(steps):
182
+ # Check resources
183
+ for position, step in enumerate(ordered):
194
184
  for field, resource in step._resources().items():
195
185
  if resource.name is None:
196
186
  raise ResourceError(
@@ -199,6 +189,15 @@ def _check_resources(steps: list[Step]) -> None:
199
189
  f'{field}=Resource("some_name", ...).'
200
190
  )
201
191
 
192
+ position = {id(step): index for index, step in enumerate(ordered)}
193
+ document = PlanDocument(
194
+ steps=tuple(
195
+ _node(step, tuple(position[id(parent)] for parent in step.parents))
196
+ for step in ordered
197
+ )
198
+ )
199
+ return document.model_dump_json(indent=indent)
200
+
202
201
 
203
202
  def _node(step: Step, inputs: tuple[int, ...]) -> StepNode:
204
203
  """Describe one step, checking its spec is the one its kind implies."""
@@ -216,61 +215,68 @@ def _node(step: Step, inputs: tuple[int, ...]) -> StepNode:
216
215
  )
217
216
 
218
217
 
219
- def load(document: PlanDocument, resources: Mapping[str, Any]) -> Step:
218
+ def load(document: str | bytes, resources: Mapping[str, Any]) -> Step:
220
219
  """Rebuild a plan from a document, returning its apex.
221
220
 
222
221
  Nothing is collected. This reconstructs the same lazy plan, so the returned step
223
222
  fingerprints identically to the one that was dumped, given the same data.
224
223
 
225
224
  Args:
226
- document: A dumped plan.
225
+ document: The JSON `dump` produced.
227
226
  resources: Name to object, for every resource the document names.
228
- `document.required_resources()` lists them.
229
227
 
230
228
  Returns:
231
229
  The plan's apex, the last step in the document.
232
230
 
233
231
  Raises:
232
+ ValidationError: If the JSON does not describe a plan document.
234
233
  ValueError: If the document is empty, names an unregistered location class, or
235
234
  wires a step to an input of the wrong kind.
236
235
  ResourceError: If a named resource was not supplied.
237
236
  """
238
- if not document.steps:
237
+ parsed = PlanDocument.model_validate_json(document)
238
+ if not parsed.steps:
239
239
  raise ValueError("A plan document needs at least one step")
240
240
 
241
+ _check_supplied(parsed, resources)
242
+
241
243
  built: list[Step] = []
242
- for position, node in enumerate(document.steps):
244
+ for position, node in enumerate(parsed.steps):
243
245
  built.append(
244
246
  _rebuild(node, position, [built[i] for i in node.inputs], resources)
245
247
  )
246
248
  return built[-1]
247
249
 
248
250
 
249
- def _bind(
250
- node: StepNode, position: int, supplied: Mapping[str, Any]
251
- ) -> dict[str, Resource]:
252
- """Turn a node's `field -> resource name` map into bound resources.
251
+ def _check_supplied(document: PlanDocument, supplied: Mapping[str, Any]) -> None:
252
+ """Reject a load missing any resource the document names, naming them all.
253
253
 
254
254
  Raises:
255
255
  ResourceError: If the document names a resource `supplied` does not have.
256
256
  """
257
- bound: dict[str, Resource] = {}
258
- for field, name in node.resources.items():
259
- if name not in supplied:
260
- raise ResourceError(
261
- f"Step {position} ({node.kind}) needs resource '{name}' for '{field}', "
262
- f"which was not supplied. Pass it when loading: "
263
- f"resources={{'{name}': ...}}."
264
- )
265
- bound[field] = Resource(name, supplied[name])
266
- return bound
257
+ missing: dict[str, str] = {}
258
+ for position, node in enumerate(document.steps):
259
+ for field, name in node.resources.items():
260
+ if name not in supplied and name not in missing:
261
+ missing[name] = f"step {position} ({node.kind}) reads it as '{field}'"
262
+ if not missing:
263
+ return
264
+
265
+ wanted = "\n".join(f" '{name}' — {where}" for name, where in missing.items())
266
+ example = ", ".join(f"'{name}': ..." for name in missing)
267
+ raise ResourceError(
268
+ f"This plan needs resources that were not supplied:\n{wanted}\n"
269
+ f"Pass them when loading: resources={{{example}}}."
270
+ )
267
271
 
268
272
 
269
273
  def _rebuild(
270
274
  node: StepNode, position: int, inputs: list[Step], supplied: Mapping[str, Any]
271
275
  ) -> Step:
272
276
  """Rebuild one step from its node and its already-rebuilt inputs."""
273
- resources = _bind(node, position, supplied)
277
+ resources = {
278
+ field: Resource(name, supplied[name]) for field, name in node.resources.items()
279
+ }
274
280
  match node.kind:
275
281
  case StepKind.SOURCE:
276
282
  spec = _expect(node, position, SourceSpec)
@@ -174,21 +174,52 @@ class SplinkLinker(Linker):
174
174
  _id_dtype_l: pl.DataType
175
175
  _id_dtype_r: pl.DataType
176
176
 
177
+ @staticmethod
178
+ def _schema_summary(data: pl.DataFrame) -> str:
179
+ """Render a compact column-to-dtype summary for diagnostics."""
180
+ return (
181
+ ", ".join(f"{column}={dtype}" for column, dtype in data.schema.items())
182
+ or "<empty>"
183
+ )
184
+
185
+ @classmethod
186
+ def _conformancy_error(cls, left: pl.DataFrame, right: pl.DataFrame) -> ValueError:
187
+ """Build a detailed error describing how the two inputs differ."""
188
+ left_only = [column for column in left.columns if column not in right.columns]
189
+ right_only = [column for column in right.columns if column not in left.columns]
190
+ shared = [column for column in left.columns if column in right.columns]
191
+ differing_dtypes = [
192
+ f"{column}: left={left.schema[column]}, right={right.schema[column]}"
193
+ for column in shared
194
+ if left.schema[column] != right.schema[column]
195
+ ]
196
+
197
+ details = [
198
+ "SplinkLinker requires input data to be conformant, meaning they "
199
+ "share the same column names and data formats.",
200
+ f"Left schema: {cls._schema_summary(left)}",
201
+ f"Right schema: {cls._schema_summary(right)}",
202
+ ]
203
+ if left_only:
204
+ details.append(f"Columns only on left: {left_only}")
205
+ if right_only:
206
+ details.append(f"Columns only on right: {right_only}")
207
+ if differing_dtypes:
208
+ details.append("Differing dtypes: " + "; ".join(differing_dtypes))
209
+ return ValueError("\n".join(details))
210
+
177
211
  def prepare(self, left: pl.DataFrame, right: pl.DataFrame) -> None:
178
212
  """Build the Splink linker over left and right, and run its training functions.
179
213
 
180
214
  Runs each function in `settings.linker_training_functions`, in order, against
181
215
  the built linker. `link()` then just predicts against the trained state.
182
216
  """
183
- if (set(left.columns) != set(right.columns)) or not left.dtypes == right.dtypes:
184
- raise ValueError(
185
- "SplinkLinker requires input data to be conformant, meaning they "
186
- "share the same column names and data formats."
187
- )
188
-
189
217
  self._id_dtype_l = left[self.left_id].dtype
190
218
  self._id_dtype_r = right[self.right_id].dtype
191
219
 
220
+ if (set(left.columns) != set(right.columns)) or not left.dtypes == right.dtypes:
221
+ raise self._conformancy_error(left, right)
222
+
192
223
  # Convert to pandas for Splink compatibility
193
224
  left_pd = left.with_columns(pl.col(self.left_id).cast(pl.String)).to_pandas()
194
225
  right_pd = right.with_columns(pl.col(self.right_id).cast(pl.String)).to_pandas()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matchlab
3
- Version: 0.1.0.dev2
3
+ Version: 0.1.0.dev4
4
4
  Summary: A local-first library for building, running and evaluating entity resolution pipelines.
5
5
  Author: Department for Business and Trade
6
6
  Project-URL: Documentation, https://uktrade.github.io/matchlab/
@@ -178,6 +178,7 @@ test/models/test_comparison.py
178
178
  test/models/test_dedupers_deterministic.py
179
179
  test/models/test_linkers_deterministic.py
180
180
  test/models/test_linkers_probabilistic.py
181
+ test/models/test_linkers_splink.py
181
182
  test/models/test_models.py
182
183
  test/plan/__init__.py
183
184
  test/plan/test_ground_truth.py
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.10.8",
3
+ "distance": 124,
4
+ "node": "g7e5c31cc44787c13778c651231dcaa28893314e6",
5
+ "dirty": true,
6
+ "branch": "matchlab",
7
+ "node_date": "2026-08-19"
8
+ }
@@ -0,0 +1,42 @@
1
+ """Direct unit tests for `SplinkLinker` behavior.
2
+
3
+ These tests cover Splink-specific schema handling and diagnostics, rather than the
4
+ shared match/no-match behavior exercised in the other linker suites.
5
+ """
6
+
7
+ import polars as pl
8
+ import pytest
9
+ from splink import SettingsCreator
10
+ from splink import blocking_rule_library as brl
11
+ from splink import comparison_library as cl
12
+
13
+ from matchlab.models.linkers.splinklinker import SplinkLinker
14
+
15
+
16
+ def test_splink_conformancy_error() -> None:
17
+ """Splink surfaces the concrete schema mismatch from the real prepare path."""
18
+ linker = SplinkLinker(
19
+ left_id="id",
20
+ right_id="id",
21
+ linker_training_functions=[],
22
+ linker_settings=SettingsCreator(
23
+ link_type="link_only",
24
+ blocking_rules_to_generate_predictions=[brl.block_on("id")],
25
+ comparisons=[cl.ExactMatch("id")],
26
+ ),
27
+ threshold=None,
28
+ )
29
+ left = pl.DataFrame({"id": [1], "company_name": ["left"]})
30
+ right = pl.DataFrame({"id": [2], "postcode": ["SW1A 1AA"]})
31
+
32
+ with pytest.raises(
33
+ ValueError,
34
+ match="SplinkLinker requires input data to be conformant",
35
+ ) as exc_info:
36
+ linker.prepare(left, right)
37
+
38
+ message = str(exc_info.value)
39
+ assert "Left schema: id=Int64, company_name=String" in message
40
+ assert "Right schema: id=Int64, postcode=String" in message
41
+ assert "Columns only on left: ['company_name']" in message
42
+ assert "Columns only on right: ['postcode']" in message
@@ -53,6 +53,11 @@ def _apex(source: Callable[..., mb.Source]) -> tuple[mb.Resolver, mb.Source, mb.
53
53
  return apex, crn, dh
54
54
 
55
55
 
56
+ def _required_resources(root: mb.Step) -> set[str]:
57
+ """What `load` would have to be given, read off the dumped JSON."""
58
+ return mb.PlanDocument.model_validate_json(mb.dump(root)).required_resources()
59
+
60
+
56
61
  def _ids_by_key(matches: pl.DataFrame, column: str) -> dict[str, int]:
57
62
  return {
58
63
  row[column]: row["root"]
@@ -293,7 +298,7 @@ def test_resource_sharing(warehouse: Engine) -> None:
293
298
  model_settings={"comparisons": ["l.crn_company = r.dh_company"]},
294
299
  ).resolve()
295
300
 
296
- assert mb.dump(plan).required_resources() == {"wh"}
301
+ assert _required_resources(plan) == {"wh"}
297
302
 
298
303
 
299
304
  def test_repeated_resource_name(
@@ -359,8 +364,8 @@ def test_resource_name_reuse(
359
364
  left = source("crn", client=mb.Resource("wh", warehouse))
360
365
  right = source("dh", client=mb.Resource("wh", sqlite_in_memory_warehouse))
361
366
 
362
- assert mb.dump(left).required_resources() == {"wh"}
363
- assert mb.dump(right).required_resources() == {"wh"}
367
+ assert _required_resources(left) == {"wh"}
368
+ assert _required_resources(right) == {"wh"}
364
369
 
365
370
 
366
371
  def test_anonymous_resources_no_collision(
@@ -73,9 +73,12 @@ def plan(source: Callable[..., Source]) -> Resolver:
73
73
  )
74
74
 
75
75
 
76
- def _transfer(document: PlanDocument) -> PlanDocument:
77
- """Send a document over the wire and back — JSON is the transfer format."""
78
- return PlanDocument.model_validate_json(document.model_dump_json())
76
+ def _parse(root: Step) -> PlanDocument:
77
+ """Dump a plan and read the JSON back, for tests about what a document says.
78
+
79
+ `load` takes the JSON as it stands. Parsing is for looking inside.
80
+ """
81
+ return PlanDocument.model_validate_json(dump(root))
79
82
 
80
83
 
81
84
  # A valid source spec, for tests about a node's shape rather than its settings.
@@ -95,7 +98,7 @@ def test_rebuild_fingerprints_match(plan: Resolver, warehouse: Engine) -> None:
95
98
  original = plan
96
99
  original.collect()
97
100
 
98
- rebuilt = load(_transfer(dump(original)), resources={"warehouse": warehouse})
101
+ rebuilt = load(dump(original), resources={"warehouse": warehouse})
99
102
  rebuilt.collect()
100
103
 
101
104
  before = [step._fp for step in lineage.walk(original)]
@@ -110,7 +113,7 @@ def test_rebuild_hits_cache(
110
113
  """A transferred plan must find the original's artifacts, not redo the work."""
111
114
  plan.collect(adapter)
112
115
 
113
- rebuilt = load(_transfer(dump(plan)), resources={"warehouse": warehouse})
116
+ rebuilt = load(dump(plan), resources={"warehouse": warehouse})
114
117
  for step in lineage.walk(rebuilt):
115
118
 
116
119
  def boom(*_a: object, _step: Step = step, **_k: object) -> None:
@@ -138,8 +141,7 @@ def test_rebuild_different_warehouse(plan: Resolver, tmp_path: Path) -> None:
138
141
  original = plan
139
142
  original.collect()
140
143
 
141
- document = _transfer(dump(original))
142
- elsewhere = load(document, resources={"warehouse": other})
144
+ elsewhere = load(dump(original), resources={"warehouse": other})
143
145
  elsewhere.collect()
144
146
 
145
147
  assert elsewhere._fp != original._fp
@@ -152,26 +154,40 @@ def test_rebuild_different_warehouse(plan: Resolver, tmp_path: Path) -> None:
152
154
  def test_rebuild_same_answer(plan: Resolver, warehouse: Engine) -> None:
153
155
  """A rebuilt plan collects to the same lookup as the original."""
154
156
  original = plan
155
- rebuilt = load(_transfer(dump(original)), resources={"warehouse": warehouse})
157
+ rebuilt = load(dump(original), resources={"warehouse": warehouse})
156
158
 
157
159
  expected = original.collect().get_lookup().sort("root")
158
160
  actual = rebuilt.collect().get_lookup().sort("root")
159
161
  assert actual.equals(expected)
160
162
 
161
163
 
164
+ def test_load_from_bytes(plan: Resolver, warehouse: Engine) -> None:
165
+ """A store hands JSON back as bytes as often as text, so `load` takes both.
166
+
167
+ The text half of this is every other round trip in this file.
168
+ """
169
+ plan.collect()
170
+ rebuilt = load(dump(plan).encode(), resources={"warehouse": warehouse})
171
+ rebuilt.collect()
172
+
173
+ assert [step._fp for step in lineage.walk(rebuilt)] == [
174
+ step._fp for step in lineage.walk(plan)
175
+ ]
176
+
177
+
162
178
  def test_document_carries_no_labels(plan: Resolver, warehouse: Engine) -> None:
163
179
  """Publishing is done to a result, so a label is not part of the plan.
164
180
 
165
181
  A source's name is not a label: it prefixes that source's columns and tags its
166
182
  rows, so it is part of the output rather than a way of finding it.
167
183
  """
168
- document = dump(plan)
184
+ document = _parse(plan)
169
185
 
170
186
  assert not any(hasattr(node, "name") for node in document.steps)
171
187
  sources = [node.spec for node in document.steps if node.kind == "source"]
172
188
  assert {spec.name for spec in sources} == {"crn", "dh"}
173
189
 
174
- rebuilt = load(_transfer(document), resources={"warehouse": warehouse})
190
+ rebuilt = load(dump(plan), resources={"warehouse": warehouse})
175
191
  assert lineage.number(rebuilt) == {
176
192
  id(step): index for index, step in enumerate(lineage.walk(rebuilt))
177
193
  }
@@ -180,11 +196,11 @@ def test_document_carries_no_labels(plan: Resolver, warehouse: Engine) -> None:
180
196
  def test_document_preserves_sharing(plan: Resolver, warehouse: Engine) -> None:
181
197
  """A view feeding two models is one node referenced twice, not two nodes."""
182
198
  original = plan
183
- document = dump(original)
199
+ document = _parse(original)
184
200
 
185
201
  assert len(document.steps) == len(lineage.walk(original))
186
202
 
187
- rebuilt = load(document, resources={"warehouse": warehouse})
203
+ rebuilt = load(dump(original), resources={"warehouse": warehouse})
188
204
  models = [step for step in lineage.walk(rebuilt) if isinstance(step, Model)]
189
205
  linkers = [model for model in models if model.right is not None]
190
206
  assert len(linkers) == 2
@@ -204,14 +220,13 @@ def test_bare_value(warehouse: Engine) -> None:
204
220
 
205
221
  def test_document_carries_no_client(plan: Resolver) -> None:
206
222
  """Locations describe where data lives; connecting is the target's business."""
207
- document = dump(plan)
208
- serialised = document.model_dump_json()
223
+ serialised = dump(plan)
209
224
 
210
225
  assert "sqlite" not in serialised # the engine's URL never leaves
211
226
  assert '"resources":{"client":"warehouse"}' in serialised # only its name does
212
227
 
213
- with pytest.raises(ResourceError, match="needs resource 'warehouse'"):
214
- load(document, resources={})
228
+ with pytest.raises(ResourceError, match="'warehouse' — step"):
229
+ load(serialised, resources={})
215
230
 
216
231
 
217
232
  def test_resource_rename_keeps_fingerprint(plan: Resolver, warehouse: Engine) -> None:
@@ -223,22 +238,21 @@ def test_resource_rename_keeps_fingerprint(plan: Resolver, warehouse: Engine) ->
223
238
  original = plan
224
239
  original.collect()
225
240
 
226
- document = _transfer(dump(original))
227
- renamed = load(
228
- document.model_copy(
229
- update={
230
- "steps": tuple(
231
- node.model_copy(
232
- update={
233
- "resources": dict.fromkeys(node.resources, "somewhere_else")
234
- }
235
- )
236
- for node in document.steps
241
+ document = _parse(original)
242
+ elsewhere = document.model_copy(
243
+ update={
244
+ "steps": tuple(
245
+ node.model_copy(
246
+ update={
247
+ "resources": dict.fromkeys(node.resources, "somewhere_else")
248
+ }
237
249
  )
238
- }
239
- ),
240
- resources={"somewhere_else": warehouse},
250
+ for node in document.steps
251
+ )
252
+ }
241
253
  )
254
+
255
+ renamed = load(elsewhere.model_dump_json(), resources={"somewhere_else": warehouse})
242
256
  renamed.collect()
243
257
 
244
258
  assert [step._fp for step in lineage.walk(renamed)] == [
@@ -251,7 +265,7 @@ def test_resource_rename_keeps_fingerprint(plan: Resolver, warehouse: Engine) ->
251
265
 
252
266
 
253
267
  def test_required_resources_list(warehouse: Engine) -> None:
254
- """A caller can see what to supply before trying to load."""
268
+ """A caller can see what to supply, before loading or from the error after."""
255
269
  frame = pl.DataFrame({"pk": ["b1"], "company": ["acme"]})
256
270
  db = read_database(
257
271
  "crn", sql="select pk, company from crn", client=Resource("wh", warehouse)
@@ -263,16 +277,23 @@ def test_required_resources_list(warehouse: Engine) -> None:
263
277
  model_settings={"comparisons": ["l.crn_company = r.dh_company"]},
264
278
  ).resolve()
265
279
 
266
- document = dump(plan)
280
+ document = _parse(plan)
267
281
  assert document.required_resources() == {"wh", "dh_frame"}
268
282
 
269
- with pytest.raises(ResourceError, match="needs resource 'dh_frame'"):
270
- load(document, resources={"wh": warehouse})
283
+ with pytest.raises(ResourceError, match="'dh_frame' — step"):
284
+ load(dump(plan), resources={"wh": warehouse})
285
+
286
+ # Supply none and every missing name comes back at once, so working from the
287
+ # error takes one more attempt rather than one per resource.
288
+ with pytest.raises(ResourceError) as complaint:
289
+ load(dump(plan), resources={})
290
+ assert "'wh'" in str(complaint.value)
291
+ assert "'dh_frame'" in str(complaint.value)
271
292
 
272
293
 
273
294
  def test_document_custom_location(plan: Resolver, warehouse: Engine) -> None:
274
295
  """Documents can represent custom locations."""
275
- document = dump(plan)
296
+ document = _parse(plan)
276
297
  broken = document.model_copy(
277
298
  update={
278
299
  "steps": tuple(
@@ -291,10 +312,10 @@ def test_document_custom_location(plan: Resolver, warehouse: Engine) -> None:
291
312
  )
292
313
 
293
314
  # It parses — a document may legitimately name a class this codebase lacks.
294
- assert _transfer(broken)
315
+ assert PlanDocument.model_validate_json(broken.model_dump_json())
295
316
 
296
317
  with pytest.raises(ValueError, match="No location class named 'S3Location'"):
297
- load(broken, resources={"warehouse": warehouse})
318
+ load(broken.model_dump_json(), resources={"warehouse": warehouse})
298
319
 
299
320
 
300
321
  # -- what the document says -----------------------------------------------------------
@@ -302,7 +323,7 @@ def test_document_custom_location(plan: Resolver, warehouse: Engine) -> None:
302
323
 
303
324
  def test_edges_point_backwards(plan: Resolver) -> None:
304
325
  """Topological order is the document's ordering guarantee."""
305
- document = dump(plan)
326
+ document = _parse(plan)
306
327
 
307
328
  assert document.steps[0].kind == "source"
308
329
  assert document.steps[-1].kind == "resolver"
@@ -315,7 +336,7 @@ def test_settings_vs_resources(warehouse: Engine) -> None:
315
336
  source = read_database(
316
337
  "ch", sql="select id, c from ch", client=Resource("wh", warehouse)
317
338
  )
318
- node = dump(source).steps[0]
339
+ node = _parse(source).steps[0]
319
340
 
320
341
  assert node.spec.location_settings == {"sql": "select id, c from ch"}
321
342
  assert node.resources == {"client": "wh"}
@@ -323,7 +344,7 @@ def test_settings_vs_resources(warehouse: Engine) -> None:
323
344
 
324
345
  def test_spec_has_settings_not_edges(plan: Resolver) -> None:
325
346
  """The split that makes a spec safe to hash and a document able to rebuild."""
326
- document = dump(plan)
347
+ document = _parse(plan)
327
348
 
328
349
  for node in document.steps:
329
350
  spec = node.spec.model_dump(mode="json")
@@ -342,14 +363,14 @@ def test_setting_travels_as_position(plan: Resolver, warehouse: Engine) -> None:
342
363
  A name would have been the alternative, and would have made the document depend on
343
364
  names being stable — the thing positions exist to avoid.
344
365
  """
345
- document = _transfer(dump(plan))
366
+ document = _parse(plan)
346
367
  apex = document.steps[-1]
347
368
 
348
369
  assert apex.kind == "resolver"
349
370
  # `1` is the second input, and JSON has stringified the key.
350
371
  assert apex.spec.resolver_settings == {"thresholds": {"1": 0.5}}
351
372
 
352
- rebuilt = load(document, resources={"warehouse": warehouse})
373
+ rebuilt = load(dump(plan), resources={"warehouse": warehouse})
353
374
  assert rebuilt.resolver_instance.thresholds == {1: 0.5}
354
375
 
355
376
 
@@ -395,7 +416,7 @@ def test_spec_parsed_by_kind() -> None:
395
416
 
396
417
  def test_load_rejects_wrong_kind(plan: Resolver, warehouse: Engine) -> None:
397
418
  """A malformed document fails at load, not with a confusing error much later."""
398
- document = dump(plan)
419
+ document = _parse(plan)
399
420
  resolver = next(node for node in document.steps if node.kind == "resolver")
400
421
  broken = PlanDocument(
401
422
  steps=tuple(
@@ -405,13 +426,13 @@ def test_load_rejects_wrong_kind(plan: Resolver, warehouse: Engine) -> None:
405
426
  )
406
427
 
407
428
  with pytest.raises(ValueError, match="must read only"):
408
- load(broken, resources={"warehouse": warehouse})
429
+ load(broken.model_dump_json(), resources={"warehouse": warehouse})
409
430
 
410
431
 
411
432
  def test_document_empty_rejected() -> None:
412
433
  """A document with no steps is rejected on load."""
413
434
  with pytest.raises(ValueError, match="at least one step"):
414
- load(PlanDocument(steps=()), resources={})
435
+ load(PlanDocument(steps=()).model_dump_json(), resources={})
415
436
 
416
437
 
417
438
  def test_rebuild_reads_resolver_as_record_step(
@@ -449,7 +470,7 @@ def test_rebuild_transform_chain(
449
470
  .resolve()
450
471
  )
451
472
 
452
- rebuilt = load(_transfer(dump(plan)), resources={"warehouse": warehouse})
473
+ rebuilt = load(dump(plan), resources={"warehouse": warehouse})
453
474
  transforms = [step for step in lineage.walk(rebuilt) if isinstance(step, Transform)]
454
475
 
455
476
  assert [t.transformer_class.__name__ for t in transforms] == ["Select", "Clean"]
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.1.0.dev2",
3
- "distance": 0,
4
- "node": "gec42d08af2b5d941d52418929e1abb6b76566dde",
5
- "dirty": false,
6
- "branch": "matchlab",
7
- "node_date": "2026-08-18"
8
- }