thisness 0.1.0__tar.gz → 0.2.0__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 (237) hide show
  1. thisness-0.2.0/CHANGELOG.md +22 -0
  2. {thisness-0.1.0 → thisness-0.2.0}/PKG-INFO +5 -1
  3. {thisness-0.1.0 → thisness-0.2.0}/README.md +4 -0
  4. {thisness-0.1.0 → thisness-0.2.0}/docs/design/05-m3-llm-layer.md +25 -0
  5. thisness-0.2.0/docs/design/08-supplier-lists.md +187 -0
  6. {thisness-0.1.0 → thisness-0.2.0}/pyproject.toml +1 -1
  7. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/benchmark/organizations.py +61 -4
  8. thisness-0.2.0/src/thisness/benchmark/rewrite.py +221 -0
  9. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/benchmark/uk.py +3 -1
  10. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/canonicalize.py +46 -0
  11. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/cli.py +130 -3
  12. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/comparisons/organization.py +28 -5
  13. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/core/decisions.py +19 -1
  14. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/core/ledger.py +2 -0
  15. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/core/run.py +19 -0
  16. thisness-0.2.0/src/thisness/display_names.py +123 -0
  17. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/engine.py +77 -1
  18. thisness-0.2.0/src/thisness/llm/prompts/suggest_labels.v1.md +12 -0
  19. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/name_only.py +13 -8
  20. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/normalize/organization.py +131 -5
  21. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/profiles/data/organization_starter.json +35 -27
  22. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/profiles/organization.py +25 -3
  23. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/storage/_commit.py +23 -1
  24. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/storage/duckdb/invariants.py +2 -1
  25. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/storage/duckdb/store.py +137 -1
  26. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/app.py +46 -1
  27. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/app.css +6 -0
  28. thisness-0.2.0/src/thisness/ui/static/icons/download.svg +17 -0
  29. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/templates/entities.html +1 -0
  30. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/templates/entity.html +14 -0
  31. thisness-0.2.0/tests/test_cleaning.py +159 -0
  32. thisness-0.2.0/tests/test_display_names.py +185 -0
  33. thisness-0.2.0/tests/test_mapping.py +122 -0
  34. thisness-0.2.0/tests/test_messy.py +43 -0
  35. thisness-0.2.0/tests/test_rewrite.py +91 -0
  36. thisness-0.2.0/tests/test_upgrade.py +44 -0
  37. {thisness-0.1.0 → thisness-0.2.0}/uv.lock +1 -1
  38. thisness-0.1.0/CHANGELOG.md +0 -12
  39. {thisness-0.1.0 → thisness-0.2.0}/.github/workflows/ci.yml +0 -0
  40. {thisness-0.1.0 → thisness-0.2.0}/.github/workflows/publish.yml +0 -0
  41. {thisness-0.1.0 → thisness-0.2.0}/.gitignore +0 -0
  42. {thisness-0.1.0 → thisness-0.2.0}/LICENSE +0 -0
  43. {thisness-0.1.0 → thisness-0.2.0}/docs/design/01-resolution-runs.md +0 -0
  44. {thisness-0.1.0 → thisness-0.2.0}/docs/design/02-core-model-and-storage.md +0 -0
  45. {thisness-0.1.0 → thisness-0.2.0}/docs/design/03-resolution-pipeline.md +0 -0
  46. {thisness-0.1.0 → thisness-0.2.0}/docs/design/04-m2-review-dossier-workbench-person.md +0 -0
  47. {thisness-0.1.0 → thisness-0.2.0}/docs/design/06-m4-taxonomy.md +0 -0
  48. {thisness-0.1.0 → thisness-0.2.0}/docs/design/07-m5-real-organizations.md +0 -0
  49. {thisness-0.1.0 → thisness-0.2.0}/docs/design/sketches/02_schema.sql +0 -0
  50. {thisness-0.1.0 → thisness-0.2.0}/docs/design/sketches/02_schema_check.out +0 -0
  51. {thisness-0.1.0 → thisness-0.2.0}/docs/design/sketches/02_schema_check.py +0 -0
  52. {thisness-0.1.0 → thisness-0.2.0}/docs/design/sketches/03_fs_sketch.out +0 -0
  53. {thisness-0.1.0 → thisness-0.2.0}/docs/design/sketches/03_fs_sketch.py +0 -0
  54. {thisness-0.1.0 → thisness-0.2.0}/docs/design/sketches/consensus_sketch.out +0 -0
  55. {thisness-0.1.0 → thisness-0.2.0}/docs/design/sketches/consensus_sketch.py +0 -0
  56. {thisness-0.1.0 → thisness-0.2.0}/docs/prd-appendix-a-llm-resolution.md +0 -0
  57. {thisness-0.1.0 → thisness-0.2.0}/docs/prd.md +0 -0
  58. {thisness-0.1.0 → thisness-0.2.0}/docs/research/entity-resolution-landscape.md +0 -0
  59. {thisness-0.1.0 → thisness-0.2.0}/docs/vision.md +0 -0
  60. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/__init__.py +0 -0
  61. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/_canonical.py +0 -0
  62. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/adjudicate.py +0 -0
  63. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/assistant.py +0 -0
  64. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/benchmark/__init__.py +0 -0
  65. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/benchmark/__main__.py +0 -0
  66. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/benchmark/data/README.md +0 -0
  67. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/benchmark/data/uk/README.md +0 -0
  68. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/benchmark/data/uk/ch.parquet +0 -0
  69. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/benchmark/data/uk/gleif.parquet +0 -0
  70. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/benchmark/data/uk/gleif_rr.parquet +0 -0
  71. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/benchmark/data/wfo_fixture.csv.gz +0 -0
  72. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/benchmark/evaluate.py +0 -0
  73. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/benchmark/oracle.py +0 -0
  74. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/benchmark/people.py +0 -0
  75. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/benchmark/taxa.py +0 -0
  76. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/browse.py +0 -0
  77. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/candidates.py +0 -0
  78. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/comparisons/__init__.py +0 -0
  79. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/comparisons/base.py +0 -0
  80. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/comparisons/person.py +0 -0
  81. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/comparisons/taxon.py +0 -0
  82. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/consensus.py +0 -0
  83. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/core/__init__.py +0 -0
  84. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/core/base.py +0 -0
  85. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/core/components.py +0 -0
  86. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/core/evidence.py +0 -0
  87. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/core/identity.py +0 -0
  88. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/core/invocation.py +0 -0
  89. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/core/observation.py +0 -0
  90. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/core/review.py +0 -0
  91. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/core/types.py +0 -0
  92. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/dossier.py +0 -0
  93. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/edges.py +0 -0
  94. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/fellegi_sunter.py +0 -0
  95. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ids.py +0 -0
  96. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/labels.py +0 -0
  97. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/llm/__init__.py +0 -0
  98. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/llm/client.py +0 -0
  99. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/llm/prices.py +0 -0
  100. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/llm/privacy.py +0 -0
  101. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/llm/prompts/.keep +0 -0
  102. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/llm/prompts/adjudicate_pairs.v1.md +0 -0
  103. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/llm/prompts/adjudicate_pairs.v2.md +0 -0
  104. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/llm/prompts/group_names.v1.md +0 -0
  105. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/llm/prompts/group_names.v2.md +0 -0
  106. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/llm/prompts/recommend_review.v1.md +0 -0
  107. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/llm/prompts/recommend_review.v2.md +0 -0
  108. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/llm/prompts.py +0 -0
  109. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/llm/providers/__init__.py +0 -0
  110. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/llm/providers/anthropic.py +0 -0
  111. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/llm/providers/function.py +0 -0
  112. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/llm/providers/openai_compatible.py +0 -0
  113. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/llm/providers/replay.py +0 -0
  114. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/llm/types.py +0 -0
  115. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/normalize/__init__.py +0 -0
  116. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/normalize/person.py +0 -0
  117. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/normalize/taxon.py +0 -0
  118. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/policy.py +0 -0
  119. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/profiles/__init__.py +0 -0
  120. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/profiles/base.py +0 -0
  121. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/profiles/data/person_starter.json +0 -0
  122. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/profiles/data/taxon_starter.json +0 -0
  123. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/profiles/person.py +0 -0
  124. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/profiles/taxon.py +0 -0
  125. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/py.typed +0 -0
  126. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/relationships.py +0 -0
  127. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/reviews.py +0 -0
  128. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/scoring.py +0 -0
  129. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/starter.py +0 -0
  130. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/storage/__init__.py +0 -0
  131. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/storage/duckdb/__init__.py +0 -0
  132. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/storage/duckdb/migrations/0001_initial.sql +0 -0
  133. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/storage/duckdb/migrations/0002_pair_constraints.sql +0 -0
  134. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/storage/duckdb/migrations/0003_model_invocations.sql +0 -0
  135. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/storage/errors.py +0 -0
  136. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/storage/protocol.py +0 -0
  137. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/training.py +0 -0
  138. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/__init__.py +0 -0
  139. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/THIRD_PARTY.md +0 -0
  140. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/app.js +0 -0
  141. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/adjustments-horizontal.svg +0 -0
  142. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/alert-triangle.svg +0 -0
  143. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/arrow-back-up.svg +0 -0
  144. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/arrows-join.svg +0 -0
  145. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/arrows-split.svg +0 -0
  146. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/briefcase.svg +0 -0
  147. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/building.svg +0 -0
  148. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/cake.svg +0 -0
  149. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/chart-dots.svg +0 -0
  150. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/chevron-down.svg +0 -0
  151. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/chevron-right.svg +0 -0
  152. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/circle-check.svg +0 -0
  153. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/circle-dashed.svg +0 -0
  154. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/circle-x.svg +0 -0
  155. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/clock.svg +0 -0
  156. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/database.svg +0 -0
  157. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/external-link.svg +0 -0
  158. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/file-text.svg +0 -0
  159. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/filter.svg +0 -0
  160. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/fingerprint.svg +0 -0
  161. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/git-branch.svg +0 -0
  162. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/git-merge.svg +0 -0
  163. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/hash.svg +0 -0
  164. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/history.svg +0 -0
  165. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/home.svg +0 -0
  166. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/id.svg +0 -0
  167. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/layout-sidebar-right.svg +0 -0
  168. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/leaf.svg +0 -0
  169. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/link.svg +0 -0
  170. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/list-check.svg +0 -0
  171. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/mail.svg +0 -0
  172. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/map-pin.svg +0 -0
  173. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/message-question.svg +0 -0
  174. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/phone.svg +0 -0
  175. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/player-play.svg +0 -0
  176. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/plus.svg +0 -0
  177. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/scale.svg +0 -0
  178. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/search.svg +0 -0
  179. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/sparkles.svg +0 -0
  180. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/timeline-event.svg +0 -0
  181. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/user.svg +0 -0
  182. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/users.svg +0 -0
  183. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/world.svg +0 -0
  184. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/icons/x.svg +0 -0
  185. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/theme.css +0 -0
  186. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/vendor/Sortable.min.js +0 -0
  187. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/vendor/htmx.min.js +0 -0
  188. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/vendor/inter-latin-ext-wght-normal.woff2 +0 -0
  189. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/static/vendor/inter-latin-wght-normal.woff2 +0 -0
  190. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/templates/_macros.html +0 -0
  191. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/templates/_panel_head.html +0 -0
  192. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/templates/base.html +0 -0
  193. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/templates/entity_tab.html +0 -0
  194. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/templates/llm.html +0 -0
  195. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/templates/model.html +0 -0
  196. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/templates/not_found.html +0 -0
  197. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/templates/panel_candidate.html +0 -0
  198. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/templates/panel_decision.html +0 -0
  199. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/templates/panel_entity.html +0 -0
  200. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/templates/panel_invocation.html +0 -0
  201. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/templates/panel_observation.html +0 -0
  202. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/templates/relationships.html +0 -0
  203. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/templates/review.html +0 -0
  204. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/templates/reviews.html +0 -0
  205. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/templates/runs.html +0 -0
  206. {thisness-0.1.0 → thisness-0.2.0}/src/thisness/ui/templates/search_results.html +0 -0
  207. {thisness-0.1.0 → thisness-0.2.0}/tests/__init__.py +0 -0
  208. {thisness-0.1.0 → thisness-0.2.0}/tests/conftest.py +0 -0
  209. {thisness-0.1.0 → thisness-0.2.0}/tests/storage_helpers.py +0 -0
  210. {thisness-0.1.0 → thisness-0.2.0}/tests/synthetic.py +0 -0
  211. {thisness-0.1.0 → thisness-0.2.0}/tests/test_adjudicate.py +0 -0
  212. {thisness-0.1.0 → thisness-0.2.0}/tests/test_assistant.py +0 -0
  213. {thisness-0.1.0 → thisness-0.2.0}/tests/test_benchmark.py +0 -0
  214. {thisness-0.1.0 → thisness-0.2.0}/tests/test_candidates.py +0 -0
  215. {thisness-0.1.0 → thisness-0.2.0}/tests/test_canonical.py +0 -0
  216. {thisness-0.1.0 → thisness-0.2.0}/tests/test_cli.py +0 -0
  217. {thisness-0.1.0 → thisness-0.2.0}/tests/test_comparisons.py +0 -0
  218. {thisness-0.1.0 → thisness-0.2.0}/tests/test_consensus.py +0 -0
  219. {thisness-0.1.0 → thisness-0.2.0}/tests/test_constraints.py +0 -0
  220. {thisness-0.1.0 → thisness-0.2.0}/tests/test_core_models.py +0 -0
  221. {thisness-0.1.0 → thisness-0.2.0}/tests/test_dossier.py +0 -0
  222. {thisness-0.1.0 → thisness-0.2.0}/tests/test_engine.py +0 -0
  223. {thisness-0.1.0 → thisness-0.2.0}/tests/test_engine_plan.py +0 -0
  224. {thisness-0.1.0 → thisness-0.2.0}/tests/test_fellegi_sunter.py +0 -0
  225. {thisness-0.1.0 → thisness-0.2.0}/tests/test_ids.py +0 -0
  226. {thisness-0.1.0 → thisness-0.2.0}/tests/test_llm.py +0 -0
  227. {thisness-0.1.0 → thisness-0.2.0}/tests/test_name_only.py +0 -0
  228. {thisness-0.1.0 → thisness-0.2.0}/tests/test_normalize_organization.py +0 -0
  229. {thisness-0.1.0 → thisness-0.2.0}/tests/test_person.py +0 -0
  230. {thisness-0.1.0 → thisness-0.2.0}/tests/test_prompt_hints.py +0 -0
  231. {thisness-0.1.0 → thisness-0.2.0}/tests/test_relationships_labels.py +0 -0
  232. {thisness-0.1.0 → thisness-0.2.0}/tests/test_reviews.py +0 -0
  233. {thisness-0.1.0 → thisness-0.2.0}/tests/test_store.py +0 -0
  234. {thisness-0.1.0 → thisness-0.2.0}/tests/test_taxon.py +0 -0
  235. {thisness-0.1.0 → thisness-0.2.0}/tests/test_training.py +0 -0
  236. {thisness-0.1.0 → thisness-0.2.0}/tests/test_ui.py +0 -0
  237. {thisness-0.1.0 → thisness-0.2.0}/tests/test_uk.py +0 -0
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 (2026-10-15)
4
+
5
+ Supplier lists (design 08).
6
+
7
+ - **The mapping export.** `engine.mapping(entity_type, as_of_seq=None)` and `thisness export mapping` give every record, its entity, the entity's name and a status (`resolved`, `in_review`, `provisional`, `unresolved`), as CSV or Parquet, as of any point in the ledger; the Workbench's entity lists download it.
8
+ - **Supplier-name cleaning.** The organization name normalizer (version 3) removes payment-processor prefixes, store and location suffixes, reference numbers and ERP notes before comparing names; a note becomes the record's status. Numbers inside a name ("Intercede 2296 Ltd", "Omega Holdings II") are kept. A trade name ("dba", "t/a") is an alias that proposes candidates and is a relationship signal, not a match. A card descriptor cut at its length limit is never taken for a whole name. On messy names the name-only plan's recall rises from .865 to .964 (synthetic) and .803 to .93 (UK).
9
+ - **Readable names.** Organization labels prefer a mixed-case spelling. A model can suggest display names for entities written in capitals (`thisness labels suggest`); a suggestion must be a spelling of the entity's own names, and accepting it is a reversible commit (`ENTITY_LABELED`), kept through later merges and splits.
10
+ - **Benchmarks.** `thisness eval --names-only` takes the UK benchmark; `--messy` adds what real extracts carry; `--plan rewrite` runs a rewrite-and-hash baseline of the kind supplier-consolidation pipelines use (design 05 §11.5).
11
+ - **Changed.** Name comparison version 5 (levels `trade_name` and `truncated_prefix`); the organization starter model is rebuilt. The name-only plan collapses on the cleaned name and no longer drops every number of three or more digits, so numbered companies reach the model apart. A store built with 0.1.0 keeps working: opening it recomputes its organization features with the new normalizer, and a model fitted for the old comparisons is replaced on the next resolve, by refitting or by the starter.
12
+
13
+ ## 0.1.0 (2026-10-10)
14
+
15
+ The first release.
16
+
17
+ - **Core** (design 01 to 03). Observations, stable entities, and a ledger of reversible merges and splits in DuckDB. Candidate generation by blocking keys, typed comparisons, Fellegi-Sunter weights fitted by EM with term-frequency adjustments, and validated-components consensus that commits only what the evidence supports and opens a review for the rest. Every decision is stored with its evidence and can be explained.
18
+ - **Review and the Workbench** (design 04). Pair constraints, a review queue ranked by impact, dossiers, relationship candidates (a shared brand, a household), and a local web Workbench to browse entities, answer reviews by dragging records, relate or merge entities, and page through possible relationships.
19
+ - **Three domains.** Organizations, people, and taxa (scientific plant names, with World Flora Online and the Catalogue of Life as authorities; design 06). Each ships a starter model for stores too small to fit their own.
20
+ - **The language-model layer** (design 05). Anthropic and OpenAI-compatible providers, budgets, retries on transient errors, record and replay. An adjudicator for contested pairs (shadow or decide; a model's "same" yields to its own "different" and to cannot-link constraints), a review assistant that suggests answers, name-only resolution, and a per-profile `model_role`.
21
+ - **Benchmarks.** Synthetic organizations and people, WFO plant names, and real UK companies from GLEIF and Companies House with company numbers as truth (design 07). `thisness eval` reports precision, recall, false-merge clusters, and a model's accuracy against the truth.
22
+ - **Scale.** Tested to 46,565 real records: bounded boundary reviews and relationship neighborhoods, a paginated relationships page, penalty-only common-value adjustments for shared addresses.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: thisness
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Identity resolution for imperfect observations: evidence, stable identities, reversible history.
5
5
  Project-URL: Homepage, https://thisness.dev
6
6
  Project-URL: Repository, https://github.com/mfbaig35r/thisness
@@ -95,8 +95,11 @@ thisness ui # the Workbench: entities, reviews, his
95
95
  thisness --store demo.duckdb demo && thisness ui demo.duckdb # try it on synthetic data
96
96
  thisness --store people.duckdb demo --type person # synthetic people
97
97
  thisness --store taxa.duckdb demo --type taxon --reference # real plant names, labelled by WFO
98
+ thisness export mapping > mapping.csv # every record, its entity and the entity's name
98
99
  ```
99
100
 
101
+ Supplier lists and spend extracts work as they are: payment-processor prefixes ("SQ *", "PAYPAL *"), store and reference numbers and ERP notes ("DO NOT USE", "ZZ ") are cleaned before names are compared, and a note becomes the record's status in the mapping. A model can suggest readable names for entities whose records are all in capitals (`thisness labels suggest`), which you accept or clear (design 08).
102
+
100
103
  The Workbench is a local web app over the same API: browse entities with the evidence for every value, answer the review queue by dragging records between entities, relate or merge entities that share a brand, page through possible relationships, and see exactly what a language model was given and what it answered.
101
104
 
102
105
  ## With a language model
@@ -164,6 +167,7 @@ The UK benchmark's hard cases are real: 62% of live UK companies share a registe
164
167
  - [05 the language-model layer](docs/design/05-m3-llm-layer.md)
165
168
  - [06 taxonomy, the third domain](docs/design/06-m4-taxonomy.md)
166
169
  - [07 real organizations](docs/design/07-m5-real-organizations.md)
170
+ - [08 supplier lists](docs/design/08-supplier-lists.md)
167
171
  - [Changelog](CHANGELOG.md)
168
172
 
169
173
  ## Development
@@ -63,8 +63,11 @@ thisness ui # the Workbench: entities, reviews, his
63
63
  thisness --store demo.duckdb demo && thisness ui demo.duckdb # try it on synthetic data
64
64
  thisness --store people.duckdb demo --type person # synthetic people
65
65
  thisness --store taxa.duckdb demo --type taxon --reference # real plant names, labelled by WFO
66
+ thisness export mapping > mapping.csv # every record, its entity and the entity's name
66
67
  ```
67
68
 
69
+ Supplier lists and spend extracts work as they are: payment-processor prefixes ("SQ *", "PAYPAL *"), store and reference numbers and ERP notes ("DO NOT USE", "ZZ ") are cleaned before names are compared, and a note becomes the record's status in the mapping. A model can suggest readable names for entities whose records are all in capitals (`thisness labels suggest`), which you accept or clear (design 08).
70
+
68
71
  The Workbench is a local web app over the same API: browse entities with the evidence for every value, answer the review queue by dragging records between entities, relate or merge entities that share a brand, page through possible relationships, and see exactly what a language model was given and what it answered.
69
72
 
70
73
  ## With a language model
@@ -132,6 +135,7 @@ The UK benchmark's hard cases are real: 62% of live UK companies share a registe
132
135
  - [05 the language-model layer](docs/design/05-m3-llm-layer.md)
133
136
  - [06 taxonomy, the third domain](docs/design/06-m4-taxonomy.md)
134
137
  - [07 real organizations](docs/design/07-m5-real-organizations.md)
138
+ - [08 supplier lists](docs/design/08-supplier-lists.md)
135
139
  - [Changelog](CHANGELOG.md)
136
140
 
137
141
  ## Development
@@ -224,3 +224,28 @@ Benchmarks: the contested pairs of the Organization and Person benchmarks (adjud
224
224
 
225
225
  - **What limits precision.** With a right model, every remaining false merge is distinct companies with identical names after collapse ("Premier Services AG" twice, "Premier Services" thirteen times): the benchmark's 25 "Premier Services" companies. From names alone they cannot be told apart; Fellegi-Sunter declines them through its common-name adjustment, which is why its precision is 1.0 and its recall half. The plan has no such guard: a key shared by distinct entities collapses. Systematic folding of related companies adds false merges as it did for the adjudicator. Both are measurements a real model run should repeat; the oracle groups common names by truth, which a real model told to treat common names with suspicion may not.
226
226
  - **Tests:** `test_name_only.py` (windows; the collapse key; one vote per stage, echoes and derived votes; recall far above Fellegi-Sunter on names with the oracle; later records joining existing entities; discarded answers and an exhausted budget leaving names provisional; folding related companies).
227
+
228
+ ### 11.5 A rewrite-and-hash baseline, measured
229
+
230
+ Supplier-consolidation pipelines often resolve names another way: a model rewrites every name in overlapping chunks to a cleaned, consolidated form, in one to three passes, and records whose final rewrites are equal (after keeping letters and digits) are one organization; a last call standardizes each group's name, and groups whose standardized names match merge. Identity is string equality of model output: no pairwise evidence, no votes, no reviews. The name-only plan was measured against it, with a real model, before deciding what to take from it.
231
+
232
+ - **The baseline** (`benchmark/rewrite.py`, `thisness eval --plan rewrite`): distinct names keyed by their letters and digits; passes over chunks of 50, 75 and 100 overlapping by 10, the first in input order and later ones in key-hash order as such pipelines build it (`--rewrite-order as_built`) or by name (`sorted`); the last answered rewrite per name; one standardization call per group. Its prompts are written for it. Records are shuffled first, since the generators emit them organization by organization and an AP extract is in no such order.
233
+ - **Results** (gpt-6-luna for every model call, names-only benchmarks):
234
+
235
+ | Benchmark | Method | Precision | Recall | False-merge clusters | Reviews | Cost |
236
+ |---|---|---|---|---|---|---|
237
+ | Synthetic, 805 organizations, 1,776 records | Fellegi-Sunter on names | 1.000 | .528 | 0 | 219 | |
238
+ | | name-only plan | .984 | .964 | 7 | 12 | $0.14 |
239
+ | | rewrite, as built | .983 | .762 | 10 | | $0.10 |
240
+ | | rewrite, passes by name | .983 | .976 | 8 | | $0.10 |
241
+ | UK, 400 sampled, 3,507 records (design 07) | Fellegi-Sunter on names | .9996 | .344 | 1 | 455 | |
242
+ | | name-only plan | .9966 | .944 | 6 | 32 | $0.16 |
243
+ | | rewrite, as built | .9977 | .825 | 5 | | $0.17 |
244
+ | | rewrite, passes by name | .9961 | .949 | 6 | | $0.16 |
245
+
246
+ - **String equality does not over-merge.** Precision is the same for every method with a model; the false merges they share are the ones names cannot settle (the synthetic "Premier Services" companies, real UK namesakes), most fixed before any model sees them by the key or the collapse.
247
+ - **Order decides the baseline's recall.** A model consolidates the variants a chunk shows together. Ordered by key hash, later passes scatter one organization's variants across chunks, and 12 to 21 points of recall are lost; ordered by name, the baseline matches the name-only plan. The standardization call merges almost nothing (1,094 groups before it, 1,092 after), so it does not repair the scatter.
248
+ - **What the name-only plan adds is not a better score.** It holds contested clusters for review (12 and 32 here) where the baseline commits everything, and every link it makes cites the votes and the exact batches behind it. It was also faster in these runs (154 s against 184 and 406 s on the synthetic benchmark, 435 s against 1,150 and 1,620 s on the UK one), since the baseline's passes run one after another.
249
+ - **What to take from the baseline** is not its identity rule but what it does around it: cleaning names of payment prefixes, store and account numbers and notes, and a standardized display name per entity. Both are design 08's subject.
250
+ - **Reproducing.** The model runs are recorded; with the recording, `thisness eval --names-only --plan rewrite --rewrite-order sorted --size 805 --seeds 20261007 --adjudicator openai --model gpt-6-luna --replay CASSETTE` (and `--source uk --size 400 --seeds 20261009`) reproduces the table's rows exactly, offline. Without a model, `--plan rewrite` uses an oracle that consolidates only what one chunk shows (and standardizes without merging): on 805 organizations it gives recall .61 as built and .996 by name, the same effect.
251
+ - **Tests:** `test_rewrite.py` (the key; chunks overlapping and covering every name; hash order losing recall to name order at equal precision; identical names of different companies merging; unanswered chunks keeping their names; a spent budget stopping calls without failing the run; names-only UK benchmarks; `eval --plan rewrite`). `names_only()` now takes the UK benchmark as well as the synthetic one.
@@ -0,0 +1,187 @@
1
+ # Design 08: supplier lists
2
+
3
+ Status: accepted 2026-10-14, with the recommendations in section 10. Complete 2026-10-15 at step 4; step 5 (model cleaning) not built (§13.5).
4
+
5
+ Builds on: design 05 (the name-only plan, §6; the rewrite-and-hash baseline, §11.5), design 07 (real organizations) and the organization profile as of commit fbe761c.
6
+
7
+ ## 1. Why
8
+
9
+ A vendor master, an accounts-payable extract or a spend cube is a list of supplier names and little else. Thisness resolves such lists with the name-only plan, which on the names-only benchmarks matches the best rewrite-and-hash pipeline on precision and recall and adds what that pipeline lacks: a review queue for what it cannot settle and the votes behind every link (design 05 §11.5). Identity is not what is missing.
10
+
11
+ What is missing is everything around it that a supplier list needs:
12
+
13
+ - **Clean names.** Real extracts carry payment-processor prefixes ("PAYPAL *ACME", "SQ *JOES CAFE"), store and account numbers ("ACME CORP #4411", "Acme - 812773"), and notes ("DO NOT USE", "DUPLICATE OF 10233"). The organization normalizer strips a legal form only from the end of a name, so "Harjas Foods PLC - 812773" keeps both the number and the "PLC" in its key.
14
+ - **A readable name per entity.** An entity's label is its most frequent spelling, so a company most often written in a registry's capitals is labelled "HALEON UK FINANCE LIMITED".
15
+ - **The mapping.** What a spend analyst takes away is a table: every source record, the entity it belongs to, and that entity's name. Thisness has the data and no export.
16
+
17
+ ## 2. What was measured first
18
+
19
+ - **Name-only resolution works without cleaning, but rules cost little.** With account numbers on a quarter of the names, Fellegi-Sunter on names loses 2 to 4 points of recall (synthetic: .571 to .528; UK: .367 to .344); the name-only plan already strips account numbers in its collapse key. The benchmarks carry no payment prefixes or notes, so their cost is unmeasured.
20
+ - **Blocking is not the bottleneck.** With names only, the candidate generators propose 96.8% of true pairs on the synthetic benchmark and 96.4% on the UK one (98.0% and 96.5% with full records). Names-only recall is low (.53 and .34 for Fellegi-Sunter) because a name is weak evidence, not because pairs go unproposed. An embedding candidate generator could raise the ceiling by at most 3.5 points here.
21
+ - **A model's string equality does not over-merge** (design 05 §11.5), so a model asked to clean names does not threaten precision by itself, provided it only cleans and the identity decision stays with the plan.
22
+
23
+ ## 3. Name cleaning by rules
24
+
25
+ A new version of the organization name normalizer (`organization_name` v3) cleans before it tokenizes. Each rule is a dictionary or pattern in the normalizer's configuration, so changing one is a new component version and a feature rebuild, as now. The raw name is never changed; cleaning only shapes the features.
26
+
27
+ | Rule | Examples | Removed |
28
+ |---|---|---|
29
+ | Payment-processor prefixes | "PAYPAL *ACME", "SQ *JOES CAFE", "TST* BLUE DOOR", "SP * SHOPNAME" | the prefix through its asterisk |
30
+ | Trailing reference numbers | "Acme - 812773", "ACME CORP #4411", "Acme Corp 812773", "Acme (Vendor 10233)" | a number at the end after a separator ("-", "#", ":") or a reference word, a bare trailing number of five or more digits, or a trailing parenthesis with a reference word |
31
+ | Store and location suffixes | "WALGREENS STORE 1022", "SHELL OIL 57442 BLDG 3" | "store", "unit", "bldg", "loc" and a number |
32
+ | Notes | "DO NOT USE", "INACTIVE", "DUPLICATE OF 10233", "(OLD)", "USE VENDOR 4411" | the note |
33
+
34
+ What the rules must not remove: numbers that are part of a name ("3M", "7-Eleven", "Studio 54"), numbered vehicles ("Omega Holdings II", "Landmark Mortgage Securities No. 3"), which the `number_differs` level relies on (design 07 §13.2), and years ("Mayfield 1966 Investments"). Trailing numbers are stripped only after a separator or a reference word, or when they have five or more digits; Roman numerals and "No. n" are never stripped.
35
+
36
+ Two things the rules find are kept rather than dropped:
37
+
38
+ - **Notes are observation metadata.** "DO NOT USE" and "INACTIVE" say something about the vendor record, not the vendor. They become a `record_status` feature, shown on the record in the Workbench and in the export, never compared.
39
+ - **Trade names are a second name, but weak.** "Acme Holdings LLC dba Acme Tools" and "T/A" forms yield two names for one record. The record's name stays as written; the trade name becomes an alias feature that candidate generation reads, and the name comparison scores a match on it at a capped level (0 bits) and treats as a relationship signal: a trade name is often a group's brand, shared by sister companies (§13.2, decision 3).
40
+
41
+ ## 4. Name cleaning by a model, only if the rules leave enough behind
42
+
43
+ What rules miss (mangled processor descriptors, free-text notes, names run together with addresses) a model can clean. It is built only if the measurement in §8 shows a residue worth the cost and the machinery:
44
+
45
+ - One call per batch of distinct raw names not already clean by the rules, cached by the raw string, so a name is cleaned once per store and the answer is replayed thereafter.
46
+ - The answer is stored as an **annotation**: a value derived from an observation by a recorded component or model invocation, never an edit to the observation. Normalizers read a name's annotation when one exists. Annotations are new (decision 4): they need a table, a ledger-free rebuild rule (dropping an annotation component rebuilds features without it) and a place in the dossier.
47
+ - The prompt only cleans; it is never asked to make two names equal. Consolidation stays with the name-only plan, where it is voted and reviewable.
48
+
49
+ ## 5. Display names
50
+
51
+ Two steps, the first free:
52
+
53
+ 1. **Prefer a readable spelling.** The organization canonicalizer picks, among the spellings of the entity's most supported name key, the one in mixed case when one exists, else the most frequent. "HALEON UK FINANCE LIMITED" (3 records) and "Haleon Uk Finance Limited" (1) share a key; the label becomes the second. Support counts are unchanged: the dossier still shows how many records carry each value.
54
+ 2. **A model's suggestion, recorded and reviewable.** For an entity whose best spelling is all capitals, abbreviated or truncated, the review assistant can suggest a display name from its variants ("Haleon UK Finance Limited"). An accepted suggestion is a ledger event (`ENTITY_LABELED`) citing the suggestion, so it is reversible and the snapshot shows it; the canonicalizer never overrides an accepted label. Suggestions are asked for in bulk, like review recommendations, and cost one short call per entity.
55
+
56
+ ## 6. The mapping export
57
+
58
+ ```bash
59
+ thisness export mapping --type organization > mapping.csv # or --format parquet
60
+ ```
61
+
62
+ `engine.mapping(entity_type, as_of_seq=None)` returns the same table:
63
+
64
+ | Column | |
65
+ |---|---|
66
+ | `source_type`, `source_id`, `locator` | the record as it was ingested |
67
+ | `name` | the record's name as written |
68
+ | `observation_id` | |
69
+ | `entity_id`, `entity_name` | its entity, and the entity's display name |
70
+ | `status` | `resolved`, `in_review` (a member of an open review), `provisional` |
71
+ | `record_status` | the note the cleaning rules found, if any (§3) |
72
+ | `since_seq`, `decision_id` | when the membership began, and the decision behind it |
73
+
74
+ `as_of_seq` exports the mapping as it stood at a point in the ledger, so a spend report can be reproduced after later merges. The Workbench gets a download link on each entity list.
75
+
76
+ ## 7. Embedding candidates, deferred
77
+
78
+ §2 shows the generators already propose nearly every true pair on these benchmarks, so an embedding generator is not part of this milestone. It stays the answer to a measured gap: if real supplier lists show pairs that share no blocking key (a renamed company with its old and new names in one list is not one; nothing in two unrelated names says they are the same), an `EmbeddingNeighbors` generator would embed cleaned names through the provider layer (OpenAI-compatible `/embeddings`, so a local server works too), cache vectors by text, and propose nearest neighbours above a similarity, to be scored like any other candidate.
79
+
80
+ ## 8. Benchmark
81
+
82
+ The names-only benchmarks gain a **messy** variant (`--names-only --messy`): on a share of records, a payment prefix, a store or location suffix, a reference number in one of the forms of §3, a note, or a trade name. The same generator settings apply to the synthetic and UK benchmarks. Measured before and after each step:
83
+
84
+ 1. Fellegi-Sunter on names and the name-only plan, on clean and messy names, before any rule (the cost of mess).
85
+ 2. The same after the rules (§3).
86
+ 3. If the residue warrants it, model cleaning (§4).
87
+ 4. Display names: the share of entities whose label is all capitals, before and after §5.1.
88
+
89
+ A regression test fixes the rules' behaviour on a list of names that must and must not change, including every example in §3.
90
+
91
+ ## 9. Order
92
+
93
+ 1. **The mapping export** (§6). Small, useful on its own, and the table every later step is checked against.
94
+ 2. **The messy benchmark and the first measurement** (§8.1).
95
+ 3. **Cleaning rules and the readable-spelling canonicalizer** (§3, §5.1), measured (§8.2, §8.4).
96
+ 4. **Display-name suggestions** (§5.2).
97
+ 5. **Model cleaning**, only if §8.2 leaves a residue worth it (§4).
98
+
99
+ ## 10. Decisions
100
+
101
+ | # | Question | Recommendation |
102
+ |---|---|---|
103
+ | 1 | What to take from rewrite-and-hash pipelines | Cleaning and display names; not their identity rule (design 05 §11.5) |
104
+ | 2 | Where cleaning lives | In the organization name normalizer, versioned; the raw name never changes |
105
+ | 3 | Trade names ("dba", "t/a") | An alias feature read by candidates; matched only at a capped level (0 bits) and as a relationship signal, since a trade name is often a group's brand. The record's name stays as written (revised 2026-10-14, §13.2) |
106
+ | 4 | Model cleaning | Deferred until the rules are measured; if built, as annotations (a new record type), never edits to observations |
107
+ | 5 | Display names | Readable spelling by rule now; model suggestions accepted as a reversible ledger event |
108
+ | 6 | Export | `thisness export mapping` and `engine.mapping()`, with `as_of_seq` |
109
+ | 7 | Embedding candidates | Deferred: blocking proposes 96 to 98% of true pairs on names alone |
110
+ | 8 | Prompts | Written for Thisness; no prompt or dictionary is copied from other pipelines |
111
+
112
+ ## 11. Risks
113
+
114
+ - **Rules that strip part of a name.** A number, a word like "Store" or a note-like phrase can be the name itself ("Store 54 Records", "The Inactive Club"). The must-not-change list in §8 is the guard, and every rule is a dictionary entry that can be removed in a new version.
115
+ - **Locale.** Payment descriptors and note conventions differ by country and by ERP. The first dictionaries are US and UK forms; others are additions, not code.
116
+ - **Annotations, if built, are a new kind of state.** They must rebuild features deterministically from recorded answers and must not let a model's cleaning become a merge by the back door. The prompt cleans one name at a time, and §2's finding about string equality is checked again on the messy benchmark before they ship.
117
+
118
+ ## 12. Not in scope
119
+
120
+ Supplier classification (industry, commodity), web enrichment, and parent-subsidiary hierarchies: relationships already carry "subsidiary of", and Level 2 data is design 07's.
121
+
122
+ ## 13. Implementation notes
123
+
124
+ ### 13.1 Step 1: the mapping export
125
+
126
+ - **As built.** `engine.mapping(entity_type, as_of_seq=None)` returns a pyarrow table with the columns of §6, from one query over the ledger: records ingested by the point, memberships valid at it, each entity's latest snapshot by then, entities created provisional and not yet promoted, and reviews open at it. `thisness export mapping [--type] [--format csv|parquet] [--output] [--as-of SEQ]` writes it (CSV to standard output without `--output`), and the Workbench's entity lists have a Mapping download. `record_status` is empty until the cleaning rules (step 3) fill it.
127
+ - **Status.** `in_review` when the record or its entity is a member of an open review; then `unresolved` (no entity), `provisional`, `resolved`. On the 46,565 UK records of design 07 §13.4, 69% of records are `in_review`: 2,629 boundary reviews are open there, and a boundary review holds every record of the entities it asks about. That is what the status says (those records' entity may still change), but an analyst may read it as doubt about the records themselves; a `review_id` column, so a report can group by the open question, is a small addition if it proves needed.
128
+ - **Cost.** 40 ms for the 46,565 records, 70 ms as of an earlier point.
129
+ - **Tests** (`test_mapping.py`): one row per record with its current entity and that entity's name; status agreeing with open reviews and entity states; a point before resolution (every record unresolved) and before ingest (no rows); a merge changing the mapping from its seq on and not before; the command in CSV and Parquet; the Workbench download, its 404 and its link.
130
+
131
+ ### 13.2 Step 2: the messy benchmark, and what mess costs
132
+
133
+ - **The variant** (`messy()`, `thisness eval --names-only --messy`): on 35% of names-only records, one of a payment-processor descriptor ("SQ *", "TST* ", "PAYPAL *" and the name in capitals, half of them cut at 22 characters as card statements cut them), an ERP note ("ZZ ", "XX-", "*INACTIVE* ", "DO NOT USE", "(OLD)", "DUPLICATE OF n", "USE VENDOR n"), a store or location suffix, a reference number ("(VENDOR n)", "REF n", "A/C n"), or a trade name ("dba", "T/A" and the name's first one or two words). It applies to the synthetic and UK benchmarks alike, on top of the account numbers names-only already appends.
134
+ - **Results** (gpt-6-luna; seeds 20261007 and 20261009; every model run recorded under `~/thisness-demo/messy`):
135
+
136
+ | Benchmark | Method | Clean P | Clean R | Messy P | Messy R | False merges, clean to messy | Reviews, clean to messy |
137
+ |---|---|---|---|---|---|---|---|
138
+ | Synthetic, 1,776 records | Fellegi-Sunter on names | 1.000 | .528 | 1.000 | .507 | 0 to 0 | 219 to 137 |
139
+ | | name-only plan | .985 | .968 | .987 | .865 | 6 to 5 | 20 to 21 |
140
+ | | rewrite, passes by name | .983 | .976 | .982 | .967 | 8 to 8 | |
141
+ | UK, 3,507 records | Fellegi-Sunter on names | .9996 | .344 | 1.000 | .335 | 1 to 0 | 466 to 340 |
142
+ | | name-only plan | .995 | .944 | .996 | .803 | 7 to 7 | 21 to 47 |
143
+ | | rewrite, passes by name | .996 | .949 | .986 | .923 | 6 to 28 | |
144
+
145
+ - **Mess costs the name-only plan recall, not precision.** 10 points on the synthetic benchmark and 14 on the UK one, at the same precision. The collapse key no longer folds a name's variants together once one of them carries a descriptor or a note (UK nodes 1,574 to 2,524), so more of them reach the model as separate items, and some are never grouped. The UK run also made more than twice the calls ($0.15 to $0.32). This is what the rules of §3 are for: a cleaned key collapses them again before any model sees them.
146
+ - **A model that cleans while it consolidates keeps recall and loses precision.** The rewrite baseline's false merges on the UK names went from 6 to 28. Read one by one, they are the cases §3 and §11 anticipated: a truncated descriptor ("TST* BRIDGEHAVEN EUROP") taken for a prefix of another company's name; numbered companies cleaned into one ("Intercede 2296 Ltd", "Intercede 2302 Ltd", "Intercede 2343 Limited"); a qualifier dropped ("BRIP 6 (Poole BC) LLP" and "BRIP 6 (Poole) LLP"); and trade names that are a group's brand ("BRIP 6 (POOLE) LLP dba BRIP 6"). It is the evidence for decision 4's rule that a model only cleans, and for the must-not-change list of §8.
147
+ - **Decision 3 revised** (accepted 2026-10-14). A trade name as an alias the name comparison reads as a second name would merge sister companies that trade under one brand, as the last case shows. Proposed: the trade name is an alias feature that candidate generation reads, and the name comparison scores at a capped level (0 bits, like `token_subset`) and treats as a relationship signal, so two records sharing only a trade name are related until other evidence says they are the same.
148
+ - **Tests** (`test_messy.py`): determinism and unchanged truth; the share of changed names and every kind of mess, truncated descriptors included; the UK variant; `--messy` requiring `--names-only`.
149
+
150
+ ### 13.3 Step 3: cleaning rules and the readable label
151
+
152
+ - **As built.** `clean_name()` in the organization normalizer (version 3) removes, before tokenizing: payment-processor prefixes; ERP notes before or after the name, which become the record's status (`do_not_use`, `inactive`, `blocked`, `old`, `duplicate`, `use_other`; a feature, shown in the mapping export, never compared); store and location suffixes; reference numbers after a separator or a reference word, bare trailing numbers of five or more digits, and any number after a legal form ("Amazon, Inc. 4411"); and trade names after "dba", "t/a", "trading as", which become an alias key. Suffixes stack and are removed until none is left. A name that is only a note keeps itself. Every pattern is in the normalizer's configuration. `test_cleaning.py` holds the lists of names that must change and must not ("3M", "7-Eleven", "Studio 54", "Omega Holdings II", "No. 3", "Mayfield 1966 Investments", "Intercede 2296 Ltd", "Old Mutual", "The Inactive Club", "Unit 4 Ltd", "Building 3").
153
+ - **Trade names** (decision 3 as revised): candidate generators pair an alias with the name it equals and records sharing an alias; the name comparison (version 5) has a `trade_name` level, neutral, capped at 0 bits, a relationship signal. It and `truncated_prefix` are placed before `else` so the term-frequency levels keep their indices.
154
+ - **A card descriptor cut short is not a name.** The first version dropped the possibly cut last word of a descriptor 22 characters long. Measured, that made two companies' descriptors identical ("SP * ASSOCIATED BRITIS", "TST* ASSOCIATED BRITIS" both "Associated") and one company's descriptor another's name ("SQ *LOW PROFILE FASHIO" read "Low Profile", a different company's): six false-merge clusters for Fellegi-Sunter and twenty for the name-only plan on messy UK names, most collapsed before any model saw them. Now the cut word is kept and the name marked (`name.truncated`); the name comparison gives a cut name that could begin the other only `truncated_prefix` (neutral, capped at 0), even when two cut names read alike; and the name-only plan never collapses one, so each reaches the model on its own.
155
+ - **The name-only plan** collapses on the cleaned key, without its former rule of dropping every number of three or more digits, so numbered companies ("Intercede 2296 Ltd", "Intercede 2302 Ltd") reach the model apart; and its windows follow the cleaned key, so "SQ *HOWDEN JOINERY" sits beside "Howden Joinery".
156
+ - **Results** (gpt-6-luna; recordings under `~/thisness-demo/cleaned2`; before = step 2, §13.2):
157
+
158
+ | Benchmark | Method | Before P | Before R | After P | After R | False merges, before to after |
159
+ |---|---|---|---|---|---|---|
160
+ | Synthetic, clean names | Fellegi-Sunter | 1.000 | .528 | 1.000 | .571 | 0 to 0 |
161
+ | | name-only plan | .985 | .968 | .990 | .956 | 6 to 5 |
162
+ | Synthetic, messy names | Fellegi-Sunter | 1.000 | .507 | 1.000 | .548 | 0 to 0 |
163
+ | | name-only plan | .987 | .865 | .986 | .964 | 5 to 6 |
164
+ | UK, clean names | Fellegi-Sunter | .9996 | .344 | .9995 | .367 | 1 to 1 |
165
+ | | name-only plan | .995 | .944 | .995 | .948 | 7 to 6 |
166
+ | UK, messy names | Fellegi-Sunter | 1.000 | .335 | .9995 | .364 | 0 to 1 |
167
+ | | name-only plan | .996 | .803 | .993 | .930 | 7 to 13 |
168
+ | Synthetic, full records | Fellegi-Sunter | .9994 | .923 | .9994 | .923 | 1 to 1 |
169
+ | UK, full records | Fellegi-Sunter | .9992 | .9119 | .9992 | .9116 | 1 to 1 |
170
+
171
+ On messy names the name-only plan recovers most of what mess cost (synthetic to the clean level; UK 12.7 of 14 points), at about half the UK cost ($0.32 to $0.23). The UK's 13 false-merge clusters, read one by one, are mostly the model joining a cut descriptor to a whole name it fits ("PAYPAL *ZODIAC MARITIM" and Zodiac Maritime Limited; "TST* ROYAL FREE HOSPIT" and Royal Free Hospital) where the benchmark gave the descriptor to a namesake: nothing in the record can tell them apart. Before the rules those descriptors sorted among the S's and P's, out of their names' windows, so they were neither merged wrongly nor found. Guidance telling the model that a cut descriptor may name another company is a possible next step. The synthetic clean run's recall fell a point (.968 to .956) with one fewer false merge, from the new window order.
172
+ - **The readable label.** The organization canonicalizer (`ReadableSpelling`) labels an entity with a spelling of its most supported cleaned key that needed no cleaning, then one in mixed case (most of its words with a lower-case letter, so "AXA XL UNDERWRITING AGENCIES Ltd." does not count), then the most frequent. On 548 UK entities, labels in capitals fell from 77% to 56%; 41% have no spelling but capitals, which is what step 4's suggestions are for. Case it cannot know stays wrong ("Axa Xl"), for the same reason.
173
+ - **The shipped organization starter** was rebuilt for name comparison version 5.
174
+ - **Tests** (`test_cleaning.py`): every name that must change, and every one that must not; statuses; trade names and descriptors; features from the cleaned name; the trade-name level, its cap and relationship signal, and the term-frequency levels' places; a cut descriptor never exact or collapsed; numbered companies apart in the collapse key; the readable label; the record status in the mapping.
175
+
176
+ ### 13.4 Step 4: display names
177
+
178
+ - **As built.** `engine.suggest_labels(entity_type, assistant=LabelAssistant(client, model), limit)` asks a model for a display name for live entities whose label is written all in capitals or all in lower case and that have no name set or suggested, twenty entities to a call, each with its eight most frequent spellings (`display_names.py`, prompt `suggest_labels` v1). Each answer is a `LabelSuggestion` decision citing its model call. It changes nothing. `engine.accept_label_suggestion(id)` labels the entity (its survivor, if it has been merged since) by a commit: an operator decision (`LABEL`) and an `ENTITY_LABELED` event carrying the name and the suggestion; `engine.label(entity, name, reason=...)` sets any name a person chooses; `engine.clear_label` undoes either (`ENTITY_LABEL_CLEARED`). Both events are identity events, so the entity's version and snapshot advance, the invariant suite counts them, and the snapshot written by any later commit takes the latest accepted name over the canonicalizer's. A merge keeps the survivor's name; an entity split off starts without one. `thisness labels suggest | list | accept [--all | IDS] | clear IDS`; in the Workbench, an entity with a suggestion shows it with "Use this name", one with a name set shows "Clear", and its history shows "Named ...".
179
+ - **A suggestion must be a spelling of the entity's own names.** Its key, by the profile's value keys, must equal one of the shown spellings' keys, or it is not recorded: casing, spacing and the legal form's spelling may change, words may not. On the trial below the one rejection was the model correcting the typo in "perion limiqted", the entity's only spelling, to "Perion Limited": a better name, but a different key, and the rule is that a model never changes a name, only how it is written.
180
+ - **Trial** (gpt-6-luna, the 548 UK entities of §13.3's label measurement; recording `~/thisness-demo/labels/suggest-gpt-6-luna.jsonl`): 300 entities asked in 15 calls for $0.0055; 299 suggestions, 1 rejected. Acronyms and initialisms stay in capitals ("DAE Aviation Holdings UK Limited", "GS Capital Partners UK LLP", "B2C2 OTC Ltd", "Haleon UK Finance (USD) Limited", "M.U. Enterprises Limited"); the rest reads in title case. Accepting every suggestion takes the entities whose label needs a name from 58% to 3% (the rest were beyond the trial's limit of 300, or the rejection).
181
+ - **A correction to step 3.** Its rule marked a descriptor as cut when it was 22 characters or longer; a descriptor longer than the limit was plainly not cut ("PAYPAL *HALEON UK FINANCE LIMITED", 33 characters, was kept apart from its company). A descriptor is now cut only when it is exactly 22 or 25 characters long, trailing spaces counted. Re-measured on the messy names (recordings under `~/thisness-demo/cleaned3`): synthetic Fellegi-Sunter R .548 to .558, name-only plan unchanged at .964 (P .985, 6 false-merge clusters); UK Fellegi-Sunter unchanged at .364, name-only plan R .930 to .928 with 15 false-merge clusters against 13, within a model run's variation.
182
+ - **Naming.** The new module is `display_names.py`: `labels.py` already holds review answers as training labels (design 04 §2.7), and `engine.labels()` returns those; accepted names are `engine.display_labels()`.
183
+ - **Tests** (`test_display_names.py`): which labels need a name; a name as a commit, its version, history and clearing; a name kept through a merge and a split, not inherited by the entity split off, refused on a retired entity; suggestions recorded with their model call and changing nothing until accepted, and not asked twice; a suggestion that is not a spelling of the entity's names rejected; accepting after a merge names the survivor; the mapping export showing the name; the `labels` command; the Workbench's accept and clear.
184
+
185
+ ### 13.5 Closing: step 5 not built
186
+
187
+ Step 5 was to be built only if the rules left a residue worth a model's cleaning (§4). They did not: on messy names the name-only plan reaches .964 recall on the synthetic benchmark, its clean level, and .93 on the UK one, and what remains is mostly card descriptors cut short, which no cleaning can complete, and the model's own judgments on them. Annotations are therefore not built. Two follow-ups remain open: guidance telling the model that a cut descriptor may name another company (§13.3), and an embedding candidate generator if a real supplier list shows pairs that share no blocking key (§7).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "thisness"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Identity resolution for imperfect observations: evidence, stable identities, reversible history."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -17,7 +17,7 @@ Proportions follow the design 03 dataset; ``n_orgs`` scales the whole set.
17
17
  from __future__ import annotations
18
18
 
19
19
  import random
20
- from dataclasses import dataclass, field
20
+ from dataclasses import dataclass, field, replace
21
21
  from typing import ClassVar
22
22
 
23
23
  SYLLABLES = [
@@ -323,10 +323,11 @@ def generate(n_orgs: int = 805, seed: int = 20261007) -> Benchmark:
323
323
  return Benchmark(seed, orgs, records)
324
324
 
325
325
 
326
- def names_only(benchmark: Benchmark, *, account_numbers: float = 0.25, seed: int = 0) -> Benchmark:
326
+ def names_only(benchmark, *, account_numbers: float = 0.25, seed: int = 0):
327
327
  """The supplier-names benchmark (design 05 §7): the same organizations and records with
328
328
  every attribute but the name removed, and a vendor or account number appended to a
329
- quarter of the names ("Harjas Foods PLC - 812773"), as in AP and ERP extracts."""
329
+ quarter of the names ("Harjas Foods PLC - 812773"), as in AP and ERP extracts. Works
330
+ on the synthetic benchmark and on the UK one (design 07)."""
330
331
  rng = random.Random(f"{seed}|names_only")
331
332
  records = []
332
333
  for r in benchmark.records:
@@ -334,5 +335,61 @@ def names_only(benchmark: Benchmark, *, account_numbers: float = 0.25, seed: int
334
335
  if rng.random() < account_numbers:
335
336
  number = rng.randrange(100_000, 999_999)
336
337
  name = rng.choice([f"{name} - {number}", f"{name} {number}", f"{name} #{number}"])
337
- records.append(Record(r.record_id, r.org_id, {"name": name}))
338
+ records.append(replace(r, attributes={"name": name}))
339
+ if not isinstance(benchmark, Benchmark):
340
+ return replace(benchmark, records=records, variant="names_only")
338
341
  return Benchmark(benchmark.seed, benchmark.organizations, records, variant="names_only")
342
+
343
+
344
+ PROCESSORS = ("PAYPAL *", "SQ *", "TST* ", "SP * ", "PP*")
345
+ NOTES_BEFORE = ("ZZ ", "*INACTIVE* ", "XX-")
346
+ NOTES_AFTER = (" - DO NOT USE", " (OLD)", " - DUPLICATE OF {n}", " INACTIVE", " - USE VENDOR {n}")
347
+ STORES = (" STORE {n}", " #{n}", " LOC {n}", " UNIT {n}", " - BLDG {n}")
348
+ REFERENCES = (" (VENDOR {n})", " REF {n}", " A/C {n}")
349
+ MESS = (
350
+ ("processor", 0.25),
351
+ ("note", 0.25),
352
+ ("store", 0.2),
353
+ ("reference", 0.15),
354
+ ("trade_name", 0.15),
355
+ )
356
+
357
+
358
+ def _mess(name: str, kind: str, rng: random.Random) -> str:
359
+ n = rng.randrange(10, 99_999)
360
+ if kind == "processor":
361
+ # A card descriptor: a processor prefix, capitals, often cut at the descriptor's
362
+ # length, so no rule can restore the rest of the name.
363
+ descriptor = rng.choice(PROCESSORS) + name.upper()
364
+ return descriptor[:22] if rng.random() < 0.5 else descriptor
365
+ if kind == "note":
366
+ if rng.random() < 0.4:
367
+ return rng.choice(NOTES_BEFORE) + name
368
+ return name + rng.choice(NOTES_AFTER).format(n=n)
369
+ if kind == "store":
370
+ return name + rng.choice(STORES).format(n=rng.randrange(2, 9_999))
371
+ if kind == "reference":
372
+ return name + rng.choice(REFERENCES).format(n=rng.randrange(1_000, 999_999))
373
+ words = name.split()
374
+ trade = " ".join(words[: max(1, min(2, len(words) - 1))])
375
+ return f"{name} {rng.choice(('DBA', 'dba', 'T/A'))} {trade}"
376
+
377
+
378
+ def messy(benchmark, *, share: float = 0.35, seed: int = 0):
379
+ """Names-only records as real extracts write them (design 08 §8): on ``share`` of the
380
+ records, one of a payment-processor descriptor (half of them truncated), an ERP
381
+ note ("ZZ ", "DO NOT USE", "DUPLICATE OF 10233"), a store or location suffix, a
382
+ reference number, or a trade name ("Acme Holdings LLC dba Acme"). Apply it to a
383
+ names-only benchmark."""
384
+ rng = random.Random(f"{seed}|messy")
385
+ kinds, weights = zip(*MESS, strict=True)
386
+ records = []
387
+ for r in benchmark.records:
388
+ name = r.attributes["name"]
389
+ if rng.random() < share:
390
+ name = _mess(name, rng.choices(kinds, weights)[0], rng)
391
+ records.append(replace(r, attributes={"name": name}))
392
+ variant = f"{benchmark.variant}+messy" if benchmark.variant else "messy"
393
+ if not isinstance(benchmark, Benchmark):
394
+ return replace(benchmark, records=records, variant=variant)
395
+ return Benchmark(benchmark.seed, benchmark.organizations, records, variant=variant)
@@ -0,0 +1,221 @@
1
+ """A rewrite-and-hash baseline for name-only resolution (design 08, measured first).
2
+
3
+ The method common in spend-analysis pipelines, reproduced to be measured against the
4
+ name-only plan (design 05 §6):
5
+
6
+ 1. Each distinct name is keyed by its letters and digits, upper-cased.
7
+ 2. One to three passes over overlapping chunks: a model rewrites every name in a chunk
8
+ to a cleaned, consolidated name, giving names it judges to be one organization the
9
+ same text. Pass 1 sees the names in input order; later passes see the previous pass's
10
+ names, ordered by key hash (as built) or by name.
11
+ 3. Each name's last answered rewrite (else the original), keyed the same way, is its
12
+ intermediate group: two records are one organization when their rewrites are equal.
13
+ 4. For each group, a model picks one standardized name from its variations; groups
14
+ whose standardized names key alike merge.
15
+
16
+ Identity is string equality of model output: there are no pairwise comparisons, votes
17
+ or reviews. The prompts here are written for this baseline.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import json
24
+ import random
25
+ import re
26
+ from collections import Counter, defaultdict
27
+ from concurrent.futures import ThreadPoolExecutor
28
+ from dataclasses import dataclass, field
29
+ from typing import Any
30
+
31
+ from pydantic import BaseModel
32
+
33
+ from thisness.benchmark.evaluate import ClusterMetrics, cluster_metrics
34
+ from thisness.llm import BudgetExhausted
35
+ from thisness.llm.prompts import Prompt
36
+ from thisness.llm.providers.function import FunctionProvider
37
+
38
+ _NOT_ALNUM = re.compile(r"[^A-Z0-9]")
39
+
40
+
41
+ def key(name: str) -> str:
42
+ return _NOT_ALNUM.sub("", name.upper())
43
+
44
+
45
+ class Rewrite(BaseModel):
46
+ ref: str
47
+ name: str
48
+
49
+
50
+ class RewriteAnswer(BaseModel):
51
+ items: list[Rewrite]
52
+
53
+
54
+ class Standardized(BaseModel):
55
+ name: str
56
+
57
+
58
+ REWRITE = Prompt(
59
+ "baseline_rewrite_names",
60
+ "1",
61
+ system="""You clean and consolidate supplier names taken from purchasing and accounts-payable data.
62
+
63
+ For every item, write a cleaned name:
64
+ - Remove what is not part of the name: account, vendor, store or reference numbers; payment-processor prefixes; internal notes.
65
+ - Fix spacing and casing, and expand obvious abbreviations (Mfg to Manufacturing) when that does not change the name.
66
+ - Keep legal forms (Ltd, Inc, GmbH, PLC).
67
+
68
+ Then consolidate: when items in the list very likely name the same organization, give them exactly the same cleaned name. Differences of punctuation, casing, spacing, abbreviation or small typos usually mean the same organization; a different word usually means a different one.
69
+
70
+ Return every item once, by its ref, with its cleaned name.""",
71
+ user="{payload}",
72
+ response=RewriteAnswer,
73
+ )
74
+
75
+ STANDARDIZE = Prompt(
76
+ "baseline_standardize_name",
77
+ "1",
78
+ system="""You pick one standardized name for a supplier from the ways different records wrote it.
79
+ Return the clearest full form of the name, with its legal form, in normal casing. Use only the variations given.""",
80
+ user="{payload}",
81
+ response=Standardized,
82
+ )
83
+
84
+ SIZES = (50, 75, 100) # chunk size per pass
85
+ OVERLAP = 10
86
+
87
+
88
+ @dataclass
89
+ class RewriteResult:
90
+ assignment: dict[str, str] # record id -> group
91
+ metrics: ClusterMetrics
92
+ stats: dict[str, Any] = field(default_factory=dict)
93
+
94
+
95
+ def _chunks(items: list[Any], size: int, overlap: int) -> list[list[Any]]:
96
+ step = max(1, size - overlap)
97
+ out, start = [], 0
98
+ while start < len(items):
99
+ out.append(items[start : start + size])
100
+ if start + size >= len(items):
101
+ break
102
+ start += step
103
+ return out
104
+
105
+
106
+ def run(benchmark, client, *, model: str, passes: int = 3, order: str = "as_built",
107
+ concurrency: int = 16, shuffle_seed: int | None = 13) -> RewriteResult: # fmt: skip
108
+ """Resolve a names-only benchmark by rewriting and hashing; score it like any run.
109
+ Records are shuffled first (``shuffle_seed``): the generators emit them organization
110
+ by organization, which would hand pass 1 every variant in one chunk; an AP extract
111
+ is in no such order."""
112
+ records = [(r.record_id, r.attributes["name"]) for r in benchmark.records]
113
+ if shuffle_seed is not None:
114
+ random.Random(shuffle_seed).shuffle(records)
115
+ display: dict[str, str] = {}
116
+ for _, name in records:
117
+ display.setdefault(key(name), name)
118
+ keys = list(display) # input order: the first record carrying each key
119
+ calls = Counter()
120
+
121
+ def ask(prompt: Prompt, payload: dict) -> Any:
122
+ try:
123
+ answer = client.call(prompt, model=model, payload=payload)
124
+ except BudgetExhausted:
125
+ calls["budget"] += 1
126
+ return None
127
+ calls["calls"] += 1
128
+ if not answer.ok:
129
+ calls["failed"] += 1
130
+ return None
131
+ return answer.parsed
132
+
133
+ passes_out: list[dict[str, str]] = []
134
+ current = {k: display[k] for k in keys}
135
+ for p in range(passes):
136
+ if p == 0:
137
+ sequence = keys
138
+ elif order == "as_built": # the next pass's input, sorted by its key's hash
139
+ sequence = sorted(current, key=lambda k: hashlib.sha256(k.encode()).hexdigest())
140
+ else:
141
+ sequence = sorted(current, key=lambda k: current[k].upper())
142
+ batches = _chunks(sequence, SIZES[min(p, len(SIZES) - 1)], OVERLAP)
143
+
144
+ def one(batch: list[str], current: dict[str, str] = current) -> list[tuple[str, str]]:
145
+ refs = {f"N{i + 1}": k for i, k in enumerate(batch)}
146
+ parsed = ask(
147
+ REWRITE, {"items": [{"ref": r, "name": current[k]} for r, k in refs.items()]}
148
+ )
149
+ if parsed is None:
150
+ return []
151
+ out = []
152
+ for item in parsed.items:
153
+ if item.ref in refs and item.name.strip():
154
+ out.append((refs[item.ref], item.name.strip()))
155
+ return out
156
+
157
+ with ThreadPoolExecutor(max_workers=concurrency) as pool:
158
+ answers = list(pool.map(one, batches))
159
+ this_pass: dict[str, str] = {}
160
+ for answer in answers: # first answer per key, in chunk order
161
+ for k, name in answer:
162
+ this_pass.setdefault(k, name)
163
+ passes_out.append(this_pass)
164
+ current = {k: this_pass[k] for k in current if k in this_pass}
165
+
166
+ # Intermediate groups: the last answered rewrite, else the original name.
167
+ group_of: dict[str, str] = {}
168
+ variations: dict[str, set[str]] = defaultdict(set)
169
+ for k in keys:
170
+ final = next((out[k] for out in reversed(passes_out) if k in out), display[k])
171
+ g = key(final) or k
172
+ group_of[k] = g
173
+ variations[g] |= {display[k], *(out[k] for out in passes_out if k in out)}
174
+
175
+ def standardize(g: str) -> tuple[str, str]:
176
+ names = sorted(variations[g])
177
+ parsed = ask(STANDARDIZE, {"variations": names})
178
+ return g, (key(parsed.name) or g) if parsed is not None else g
179
+
180
+ with ThreadPoolExecutor(max_workers=concurrency) as pool:
181
+ final_of = dict(pool.map(standardize, sorted(variations)))
182
+
183
+ assignment = {rid: final_of[group_of[key(name)]] for rid, name in records}
184
+ metrics = cluster_metrics(assignment, benchmark.truth, benchmark.kind)
185
+ stats = {
186
+ "names": len(records), "keys": len(keys),
187
+ "intermediate_groups": len(variations), "final_groups": len(set(final_of.values())),
188
+ "answered_per_pass": [len(o) for o in passes_out],
189
+ "calls": calls["calls"], "failed": calls["failed"], "budget_stops": calls["budget"],
190
+ "spent_usd": round(client.spent_usd, 6), "order": order, "passes": passes, "model": model,
191
+ } # fmt: skip
192
+ return RewriteResult(assignment, metrics, stats)
193
+
194
+
195
+ def oracle(benchmark) -> FunctionProvider:
196
+ """A model that consolidates only what it sees together, from the truth (design 05
197
+ §11.5). In a rewrite chunk, items of one organization get the name of the first of
198
+ them; an item alone with its organization keeps its name. Standardization returns a
199
+ group's own first variation, so it merges groups only when those coincide, as the
200
+ real runs found (1,094 groups before it, 1,092 after). A name is read as the organization most of its
201
+ records belong to, as a model cannot tell two companies of one name apart."""
202
+ owners: dict[str, Counter] = defaultdict(Counter)
203
+ for r in benchmark.records:
204
+ owners[key(r.attributes["name"])][benchmark.truth[r.record_id]] += 1
205
+
206
+ def org_of(name: str) -> str | None:
207
+ found = owners.get(key(name))
208
+ return found.most_common(1)[0][0] if found else None
209
+
210
+ def answer(request) -> dict:
211
+ payload = json.loads(request.messages[0].content)
212
+ if "variations" in payload:
213
+ return {"name": payload["variations"][0]}
214
+ first: dict[str | None, str] = {}
215
+ items = []
216
+ for item in payload["items"]:
217
+ org = org_of(item["name"]) or item["ref"]
218
+ items.append({"ref": item["ref"], "name": first.setdefault(org, item["name"])})
219
+ return {"items": items}
220
+
221
+ return FunctionProvider(answer, name="oracle")