jlink 0.1.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 (52) hide show
  1. jlink-0.1.0/.github/workflows/publish.yml +22 -0
  2. jlink-0.1.0/.github/workflows/test.yml +13 -0
  3. jlink-0.1.0/.gitignore +8 -0
  4. jlink-0.1.0/LICENSE +21 -0
  5. jlink-0.1.0/PKG-INFO +278 -0
  6. jlink-0.1.0/README.md +258 -0
  7. jlink-0.1.0/SPEC.md +254 -0
  8. jlink-0.1.0/bench/BASELINES.md +97 -0
  9. jlink-0.1.0/bench/FIRM_DATA.md +106 -0
  10. jlink-0.1.0/bench/README.md +58 -0
  11. jlink-0.1.0/bench/__init__.py +1 -0
  12. jlink-0.1.0/bench/baselines.py +205 -0
  13. jlink-0.1.0/bench/data.py +97 -0
  14. jlink-0.1.0/bench/live.py +132 -0
  15. jlink-0.1.0/bench/prepare.py +271 -0
  16. jlink-0.1.0/bench/report.py +135 -0
  17. jlink-0.1.0/bench/run.py +141 -0
  18. jlink-0.1.0/examples/compustat_sample.csv +13 -0
  19. jlink-0.1.0/examples/patent_assignees.csv +17 -0
  20. jlink-0.1.0/pyproject.toml +43 -0
  21. jlink-0.1.0/r/jlink.R +86 -0
  22. jlink-0.1.0/src/jlink/__init__.py +19 -0
  23. jlink-0.1.0/src/jlink/__main__.py +3 -0
  24. jlink-0.1.0/src/jlink/audit.py +297 -0
  25. jlink-0.1.0/src/jlink/block.py +302 -0
  26. jlink-0.1.0/src/jlink/cli.py +329 -0
  27. jlink-0.1.0/src/jlink/core.py +244 -0
  28. jlink-0.1.0/src/jlink/fields.py +67 -0
  29. jlink-0.1.0/src/jlink/io.py +117 -0
  30. jlink-0.1.0/src/jlink/judge.py +158 -0
  31. jlink-0.1.0/src/jlink/linker.py +233 -0
  32. jlink-0.1.0/src/jlink/resolve.py +351 -0
  33. jlink-0.1.0/stata/jlink.ado +127 -0
  34. jlink-0.1.0/stata/jlink.sthlp +55 -0
  35. jlink-0.1.0/tests/fakes.py +27 -0
  36. jlink-0.1.0/tests/test_audit.py +274 -0
  37. jlink-0.1.0/tests/test_bench_baselines.py +384 -0
  38. jlink-0.1.0/tests/test_block.py +428 -0
  39. jlink-0.1.0/tests/test_cli.py +256 -0
  40. jlink-0.1.0/tests/test_fields.py +31 -0
  41. jlink-0.1.0/tests/test_io.py +121 -0
  42. jlink-0.1.0/tests/test_judge.py +129 -0
  43. jlink-0.1.0/tests/test_linker.py +99 -0
  44. jlink-0.1.0/tests/test_resolve.py +225 -0
  45. jlink-0.1.0/tests/wrappers/.gitignore +2 -0
  46. jlink-0.1.0/tests/wrappers/README.md +53 -0
  47. jlink-0.1.0/tests/wrappers/check.R +22 -0
  48. jlink-0.1.0/tests/wrappers/check.do +64 -0
  49. jlink-0.1.0/tests/wrappers/fake_cli.py +52 -0
  50. jlink-0.1.0/tests/wrappers/run.py +110 -0
  51. jlink-0.1.0/tests/wrappers/test_wrappers.py +17 -0
  52. jlink-0.1.0/uv.lock +1311 -0
@@ -0,0 +1,22 @@
1
+ name: publish
2
+ # Push a tag like v0.1.0 to release. PyPI trusts this workflow directly (Trusted Publishing),
3
+ # so there is no API token to store, leak or rotate.
4
+ on:
5
+ push:
6
+ tags: ["v*"]
7
+ jobs:
8
+ pypi:
9
+ runs-on: ubuntu-latest
10
+ environment: pypi
11
+ permissions:
12
+ id-token: write
13
+ contents: read
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: astral-sh/setup-uv@v5
17
+ - name: The tag must match the version in pyproject.toml
18
+ run: test "v$(grep -m1 '^version' pyproject.toml | cut -d'"' -f2)" = "$GITHUB_REF_NAME"
19
+ - run: uv sync --group bench
20
+ - run: uv run pytest -q
21
+ - run: uv build
22
+ - run: uv publish
@@ -0,0 +1,13 @@
1
+ name: test
2
+ on: [push, pull_request]
3
+ jobs:
4
+ test:
5
+ runs-on: ubuntu-latest
6
+ strategy:
7
+ matrix:
8
+ python: ["3.10", "3.13"]
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ - uses: astral-sh/setup-uv@v5
12
+ - run: uv sync --group bench --python ${{ matrix.python }}
13
+ - run: uv run pytest -q
jlink-0.1.0/.gitignore ADDED
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ dist/
5
+ bench/data/
6
+ bench/out/
7
+ .env
8
+ *.key
jlink-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Khaled Eltokhy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
jlink-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,278 @@
1
+ Metadata-Version: 2.5
2
+ Name: jlink
3
+ Version: 0.1.0
4
+ Summary: Record linkage for economists: match rules in plain English, judged by TypeSafe's Jev model
5
+ Project-URL: Repository, https://github.com/keltokhy/jlink
6
+ Author: Khaled Eltokhy
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: economics,entity resolution,fuzzy matching,jev,record linkage,typesafe
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: httpx>=0.27
14
+ Requires-Dist: numpy>=1.24
15
+ Requires-Dist: pandas>=2.0
16
+ Requires-Dist: scikit-learn>=1.3
17
+ Requires-Dist: scipy>=1.10
18
+ Requires-Dist: tqdm>=4.60
19
+ Description-Content-Type: text/markdown
20
+
21
+ # jlink
22
+
23
+ Record linkage where you write the match rule in plain English.
24
+
25
+ ```python
26
+ import jlink
27
+
28
+ result = jlink.link(
29
+ compustat, patents,
30
+ entity="firm",
31
+ on=[("conm", "assignee"), "state"],
32
+ definition="A parent company and its subsidiary are different firms. "
33
+ "A firm that changed its legal form (Inc. to LLC) is the same firm.",
34
+ left_id="gvkey", right_id="assignee_id",
35
+ )
36
+
37
+ result.links # gvkey, assignee_id, p (probability of a match), margin, ...
38
+ print(result.report()) # pairs compared, links made, what it cost
39
+ print(result.methods()) # a paragraph for your data appendix
40
+ ```
41
+
42
+ String distance links "Acme Widgets Inc." to "ACME WIDGETS, INC". It does not link "IBM" to
43
+ "International Business Machines", and it cannot know that you want a subsidiary kept apart
44
+ from its parent. A research assistant can do both, slowly. jlink asks
45
+ [Jev](https://docs.typesafe.ai), a decision model from TypeSafe, the question a research
46
+ assistant would answer: given these two records and this rule, are they the same firm? Jev
47
+ returns a probability in about a fifth of a second for about a thousandth of a cent. In the
48
+ benchmarks below, 146,119 candidate pairs cost $2.49 in total, at about 250 pairs a second.
49
+
50
+ jlink is built for the way applied economists link data:
51
+
52
+ - **The rule is part of the method.** You state what counts as a match, and that sentence goes
53
+ in your appendix. Change the sentence and you change the linkage.
54
+ - **Every pair gets a probability**, so you can set a threshold, require a margin over the
55
+ runner-up, or carry the uncertainty into estimation.
56
+ - **You can check it.** jlink draws a stratified sample for hand-labeling and turns your labels
57
+ into precision, recall and a calibration table with confidence intervals.
58
+ - **It reproduces.** Every probability is stored. Rerunning costs nothing and returns the same
59
+ links.
60
+ - **It works from Python, the command line, Stata and R**, on `.csv`, `.dta` and `.parquet`.
61
+
62
+ ## Before you start: where your data goes
63
+
64
+ jlink sends the fields you list in `on`, for each candidate pair, to an outside API (TypeSafe
65
+ or OpenRouter). Do not use it on confidential or restricted-use data, such as Census RDC
66
+ files, identified administrative records or anything under a data use agreement, unless that
67
+ agreement allows it. Only the `on` fields leave your machine; blocking runs locally.
68
+
69
+ ## Install
70
+
71
+ ```bash
72
+ uv add git+https://github.com/keltokhy/jlink # in a project, for `import jlink`
73
+ uv tool install git+https://github.com/keltokhy/jlink # the command line, which Stata and R also use
74
+ ```
75
+
76
+ Both need [uv](https://docs.astral.sh/uv/). Add `pyarrow` if you read or write `.parquet`.
77
+
78
+ You need a key for one of two APIs. With keys for both, jlink uses TypeSafe's.
79
+
80
+ | API | Environment variable | Get a key |
81
+ |---|---|---|
82
+ | TypeSafe | `TYPESAFE_API_KEY` | [console.typesafe.ai](https://console.typesafe.ai/settings/keys) |
83
+ | OpenRouter | `OPENROUTER_API_KEY` | [openrouter.ai/keys](https://openrouter.ai/keys) |
84
+
85
+ A key can also live in `~/.config/jev/typesafe.key` or `~/.config/jev/openrouter.key`.
86
+
87
+ ## Try it
88
+
89
+ The repository ships a toy example: twelve Compustat-style firms and sixteen patent assignees.
90
+
91
+ ```bash
92
+ jev-link link examples/compustat_sample.csv examples/patent_assignees.csv \
93
+ --on conm=assignee --entity firm --left-id gvkey --right-id assignee_id --how many-to-many \
94
+ --define "A subsidiary or division counts as its parent company. Different companies that merely share a word are not a match." \
95
+ --block ngrams:conm=assignee:5 --block initials:conm=assignee --block exact:state -o links.csv
96
+ ```
97
+
98
+ ```
99
+ 0.97 INTL BUSINESS MACHINES CORP <- IBM [initials]
100
+ 0.98 INTL BUSINESS MACHINES CORP <- International Business Machines Corporation
101
+ 0.97 MINNESOTA MINING & MFG CO <- 3M Company [exact:state]
102
+ 0.94 UNITED TECHNOLOGIES CORP <- Pratt & Whitney (United Technologies)
103
+ 0.87 CORNING INC <- Corning Glass Works
104
+ 0.80 EXXON CORP <- Exxon Research and Engineering Co.
105
+ 0.69 FORD MOTOR CO <- Ford Global Technologies
106
+ ... and five plain matches (Abbott Labs, The Boeing Company, ...)
107
+ not linked: Westinghouse Air Brake Company, Abbott Ball Company, General Electrodynamics Corp, Rockwell Automation
108
+ ```
109
+
110
+ Fifty pairs, $0.0007. Jev knows that 3M is Minnesota Mining and that Westinghouse Air Brake is
111
+ not Westinghouse Electric. It can only judge pairs that blocking proposes, though: "3M Company"
112
+ shares no letters with "Minnesota Mining & Mfg", so it was found only because the
113
+ `exact:state` pass paired firms within a state.
114
+
115
+ ## Benchmarks
116
+
117
+ Five public datasets with known matches, run end to end (blocking, judging, resolving) on
118
+ 2026-09-18 with Jev 1.13 through OpenRouter. jlink used its default threshold of 0.5 everywhere.
119
+ Nothing was tuned on the answers. Each string baseline, by contrast, was given the single
120
+ threshold that maximizes its F1 on the answers, so the baseline column is an upper bound on
121
+ what that method can do.
122
+
123
+ | Dataset | Records | jlink precision / recall | jlink F1 | Best string baseline F1 | Pairs judged | Cost |
124
+ |---|---:|---:|---:|---:|---:|---:|
125
+ | Firms: NBER patent assignees to Compustat | 4,585 x 2,488 | 0.90 / 0.62 | **0.73** | 0.69 | 45,567 | $0.62 |
126
+ | Publications: DBLP to ACM | 2,616 x 2,294 | 0.99 / 1.00 | **0.996** | 0.975 | 26,146 | $0.45 |
127
+ | Products: Abt to Buy | 1,081 x 1,092 | 0.90 / 0.93 | **0.92** | 0.86 | 10,796 | $0.22 |
128
+ | Software: Amazon to Google | 1,363 x 3,226 | 0.60 / 0.74 | **0.66** | 0.64 | 13,610 | $0.22 |
129
+ | People: FEBRL4, synthetic typos | 5,000 x 5,000 | 1.00 / 0.92 | 0.96 | **0.998** | 50,000 | $0.97 |
130
+
131
+ The string baselines are best-match Jaro-Winkler and best-match TF-IDF cosine; the table shows
132
+ the better of the two. Exact matching after normalization scores 0.26, 0.41, 0.00, 0.00 and
133
+ 0.22.
134
+
135
+ What the table says:
136
+
137
+ - **Where names carry meaning, jlink wins without tuning.** On firms, products and publications
138
+ it beats string similarity that was handed its best threshold.
139
+ - **Where records differ only by typos, you do not need it.** FEBRL4 is synthetic person data
140
+ with character-level corruption, and TF-IDF cosine is nearly perfect there and free. jlink
141
+ made no false links (precision 1.00) but was too cautious at 0.5; at a threshold of 0.3 its
142
+ F1 is 0.98.
143
+ - **The firm benchmark has a ceiling that no name-based method can pass.** A third of the NBER
144
+ crosswalk's links are ownership facts with nothing in common in the names ("Homogeneous
145
+ Metals Inc" to "United Technologies Corp"), so blocking can propose only 67% of true links.
146
+ jlink found 93% of those. (A sliver of its error is the benchmark's: 11 Compustat names appear
147
+ under two IDs, which accounts for 14 of jlink's 331 false links.)
148
+ - **Amazon to Google is hard for everyone**, because listings differ in version and edition
149
+ details that the records often omit.
150
+
151
+ The firm rule was revised once, after reading the errors of a 300-record pilot, as a user
152
+ would: the first draft did not tell Jev that `CPY` means Company in these data. That changed F1
153
+ by two points. The final rule is two sentences: *"Ignore legal-form suffixes (Inc, Corp, Co,
154
+ CPY, Ltd, GmbH, N V). A subsidiary or division counts as its parent company."*
155
+
156
+ **Are the probabilities calibrated?** Roughly, and it depends on the data. On the firm
157
+ benchmark, pairs scored above 0.8 were true matches 95 to 98% of the time and pairs scored
158
+ below 0.2 were true 0.1% of the time, but the 0.5 to 0.8 band was overconfident (mean 0.64,
159
+ true 39% of the time). On FEBRL4 Jev was
160
+ underconfident: pairs in the 0.2 to 0.5 band were true matches 66% of the time. Treat `p` as a
161
+ strong ranking and check the middle band with an audit sample before using it as a literal
162
+ probability.
163
+
164
+ **Speed.** The firm run judged 45,567 pairs in 176 seconds (259 pairs a second, 64 calls in
165
+ flight, median latency 214 ms, one retry). Blocking 100,000 by 100,000 records takes about four
166
+ minutes and 1 GB on an M3 Ultra; blocking time grows roughly with the square of the data, so
167
+ beyond that size add an `exact` pass on a field such as state or year to split the problem.
168
+
169
+ Reproduce everything: `uv sync --group bench`, `uv run python bench/prepare.py`, then
170
+ `uv run python bench/live.py nber-firms --budget 1.00`. Baselines and data provenance are in
171
+ `bench/BASELINES.md` and `bench/FIRM_DATA.md`.
172
+
173
+ ## How it works
174
+
175
+ 1. **Block.** Comparing every record with every other is wasteful, so jlink first proposes
176
+ candidate pairs on your machine, at no cost. By default each left record is paired with its
177
+ ten nearest right records by character n-grams. Add passes for what n-grams miss:
178
+ `jlink.block.initials("name")` pairs "IBM" with "International Business Machines", and
179
+ `jlink.block.exact("state")` pairs everything within a state.
180
+ 2. **Judge.** Each candidate pair goes to Jev with your rule. Pairs whose fields are identical
181
+ after normalizing case, accents and punctuation are accepted without a call. Likelier pairs
182
+ are judged first, so if a budget runs out it is the long shots that go unjudged.
183
+ 3. **Resolve.** Choose links from the probabilities: `one-to-one` (the default; the best
184
+ overall assignment with no record used twice), `many-to-one`, `one-to-many` or
185
+ `many-to-many`, with a probability threshold and an optional margin over the runner-up.
186
+ `result.relink(...)` tries other rules without paying again.
187
+ 4. **Audit.** `result.audit_sample(200)` draws pairs across the probability range, links and
188
+ non-links alike, with both records side by side. Label them in a spreadsheet, then
189
+ `jlink.evaluate(labeled)` reports precision, recall and calibration.
190
+
191
+ ```python
192
+ linker = jlink.Linker(
193
+ entity="firm", on=[("conm", "assignee"), "state"], definition="...",
194
+ blockers=[jlink.block.ngrams(("conm", "assignee"), k=10), jlink.block.initials(("conm", "assignee"))],
195
+ )
196
+ linker.estimate(compustat, patents, left_id="gvkey", right_id="assignee_id") # blocking only, no API calls
197
+ # {'left': 4585, 'right': 2488, 'pairs': 45567, 'dollars': 0.6316, 'seconds': 227.8}
198
+ # (the real run on these data cost $0.62 and took 176 seconds)
199
+
200
+ result = linker.link(compustat, patents, left_id="gvkey", right_id="assignee_id", budget=2.00)
201
+ strict = result.relink(threshold=0.9, min_margin=0.3) # no new calls
202
+ panel = strict.merged() # both tables side by side, plus p
203
+ result.save("linkage/") # links.csv, scores.csv, settings.json
204
+ ```
205
+
206
+ ## Checking the links
207
+
208
+ ```python
209
+ sample = result.audit_sample(n=200)
210
+ sample.to_csv("audit.csv", index=False) # fill in is_match with 1 or 0, then:
211
+
212
+ ev = jlink.evaluate(pd.read_csv("audit.csv"))
213
+ print(ev.summary())
214
+ print(ev.to_markdown()) # a table for the appendix
215
+ ```
216
+
217
+ The sample is stratified by probability, so the uncertain middle is covered and not only the
218
+ easy ends, and the estimates are weighted back to all judged pairs. Recall is measured among
219
+ candidate pairs. A true match that blocking never proposed is invisible to the audit, so widen
220
+ blocking (a larger `k`, an extra pass) and see whether new links appear.
221
+
222
+ ## Command line, Stata and R
223
+
224
+ ```bash
225
+ jlink estimate compustat.dta patents.csv --on conm=assignee --on state
226
+ jlink link compustat.dta patents.csv --on conm=assignee --on state --entity firm \
227
+ --define "A parent company and its subsidiary are different firms." \
228
+ --left-id gvkey --right-id assignee_id --block ngrams:conm=assignee:10 --block initials:conm=assignee \
229
+ -o links.csv --scores scores.csv --report report.md
230
+ jlink audit scores.csv --left compustat.dta --right patents.csv --on conm=assignee -n 200 -o audit.csv
231
+ jlink evaluate audit.csv --markdown
232
+ ```
233
+
234
+ The Stata and R wrappers are single files in this repository, not part of the Python package: copy
235
+ `stata/jlink.ado` and `stata/jlink.sthlp` to your personal ado directory (`sysdir` shows it), and
236
+ `source()` `r/jlink.R`. Both call the installed command, so install the package first.
237
+
238
+ macOS ships a Java tool at `/usr/bin/jlink`. If `jlink` opens a Java prompt, use `jev-link`,
239
+ which is the same program, or `python -m jlink`.
240
+
241
+ ```stata
242
+ use compustat, clear
243
+ jlink using patents.dta, on(conm=assignee state) entity(firm) leftid(gvkey) rightid(assignee_id) ///
244
+ define("A parent company and its subsidiary are different firms.") saving(links.dta)
245
+ ```
246
+
247
+ ```r
248
+ source("r/jlink.R")
249
+ links <- jlink(compustat, patents, on = c("conm=assignee", "state"), entity = "firm",
250
+ left_id = "gvkey", right_id = "assignee_id")
251
+ ```
252
+
253
+ ## Limits
254
+
255
+ - These are a model's judgments. Audit a sample before you rely on the links.
256
+ - Jev can only judge pairs that blocking proposes. If the names share nothing, add a pass that
257
+ brings the pair together some other way (`exact` on state, year or industry).
258
+ - Jev reads the fields you give it and nothing else. It does not look anything up, and what it
259
+ knows about firms stops at its training data.
260
+ - Repeated calls return nearly but not exactly the same probability (within 0.03 in our tests).
261
+ Saved scores make results exact: keep `scores.csv` with your replication files.
262
+ - The default model ID is an alias for the latest Jev. Pin one with `model=` or `--model`
263
+ (for example `typesafe/jev-1.13` on OpenRouter) and report it; `result.methods()` does.
264
+ - The Stata and R wrappers were run on macOS against Stata 19.5 and R 4.5.1. They do not
265
+ support Windows yet.
266
+
267
+ ## Development
268
+
269
+ ```bash
270
+ uv sync --group bench && uv run pytest # 279 tests, offline, no key; Stata and R tests skip if absent
271
+ ```
272
+
273
+ `SPEC.md` is the design contract the modules were built against. `src/jlink/core.py` is the
274
+ Jev client (two backends, retries, cache, cost meter), shared verbatim with
275
+ [jgrep](https://github.com/keltokhy/jgrep), which is grep with a description in place of a
276
+ pattern.
277
+
278
+ MIT license. The benchmark datasets keep their own terms; see `bench/FIRM_DATA.md`.
jlink-0.1.0/README.md ADDED
@@ -0,0 +1,258 @@
1
+ # jlink
2
+
3
+ Record linkage where you write the match rule in plain English.
4
+
5
+ ```python
6
+ import jlink
7
+
8
+ result = jlink.link(
9
+ compustat, patents,
10
+ entity="firm",
11
+ on=[("conm", "assignee"), "state"],
12
+ definition="A parent company and its subsidiary are different firms. "
13
+ "A firm that changed its legal form (Inc. to LLC) is the same firm.",
14
+ left_id="gvkey", right_id="assignee_id",
15
+ )
16
+
17
+ result.links # gvkey, assignee_id, p (probability of a match), margin, ...
18
+ print(result.report()) # pairs compared, links made, what it cost
19
+ print(result.methods()) # a paragraph for your data appendix
20
+ ```
21
+
22
+ String distance links "Acme Widgets Inc." to "ACME WIDGETS, INC". It does not link "IBM" to
23
+ "International Business Machines", and it cannot know that you want a subsidiary kept apart
24
+ from its parent. A research assistant can do both, slowly. jlink asks
25
+ [Jev](https://docs.typesafe.ai), a decision model from TypeSafe, the question a research
26
+ assistant would answer: given these two records and this rule, are they the same firm? Jev
27
+ returns a probability in about a fifth of a second for about a thousandth of a cent. In the
28
+ benchmarks below, 146,119 candidate pairs cost $2.49 in total, at about 250 pairs a second.
29
+
30
+ jlink is built for the way applied economists link data:
31
+
32
+ - **The rule is part of the method.** You state what counts as a match, and that sentence goes
33
+ in your appendix. Change the sentence and you change the linkage.
34
+ - **Every pair gets a probability**, so you can set a threshold, require a margin over the
35
+ runner-up, or carry the uncertainty into estimation.
36
+ - **You can check it.** jlink draws a stratified sample for hand-labeling and turns your labels
37
+ into precision, recall and a calibration table with confidence intervals.
38
+ - **It reproduces.** Every probability is stored. Rerunning costs nothing and returns the same
39
+ links.
40
+ - **It works from Python, the command line, Stata and R**, on `.csv`, `.dta` and `.parquet`.
41
+
42
+ ## Before you start: where your data goes
43
+
44
+ jlink sends the fields you list in `on`, for each candidate pair, to an outside API (TypeSafe
45
+ or OpenRouter). Do not use it on confidential or restricted-use data, such as Census RDC
46
+ files, identified administrative records or anything under a data use agreement, unless that
47
+ agreement allows it. Only the `on` fields leave your machine; blocking runs locally.
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ uv add git+https://github.com/keltokhy/jlink # in a project, for `import jlink`
53
+ uv tool install git+https://github.com/keltokhy/jlink # the command line, which Stata and R also use
54
+ ```
55
+
56
+ Both need [uv](https://docs.astral.sh/uv/). Add `pyarrow` if you read or write `.parquet`.
57
+
58
+ You need a key for one of two APIs. With keys for both, jlink uses TypeSafe's.
59
+
60
+ | API | Environment variable | Get a key |
61
+ |---|---|---|
62
+ | TypeSafe | `TYPESAFE_API_KEY` | [console.typesafe.ai](https://console.typesafe.ai/settings/keys) |
63
+ | OpenRouter | `OPENROUTER_API_KEY` | [openrouter.ai/keys](https://openrouter.ai/keys) |
64
+
65
+ A key can also live in `~/.config/jev/typesafe.key` or `~/.config/jev/openrouter.key`.
66
+
67
+ ## Try it
68
+
69
+ The repository ships a toy example: twelve Compustat-style firms and sixteen patent assignees.
70
+
71
+ ```bash
72
+ jev-link link examples/compustat_sample.csv examples/patent_assignees.csv \
73
+ --on conm=assignee --entity firm --left-id gvkey --right-id assignee_id --how many-to-many \
74
+ --define "A subsidiary or division counts as its parent company. Different companies that merely share a word are not a match." \
75
+ --block ngrams:conm=assignee:5 --block initials:conm=assignee --block exact:state -o links.csv
76
+ ```
77
+
78
+ ```
79
+ 0.97 INTL BUSINESS MACHINES CORP <- IBM [initials]
80
+ 0.98 INTL BUSINESS MACHINES CORP <- International Business Machines Corporation
81
+ 0.97 MINNESOTA MINING & MFG CO <- 3M Company [exact:state]
82
+ 0.94 UNITED TECHNOLOGIES CORP <- Pratt & Whitney (United Technologies)
83
+ 0.87 CORNING INC <- Corning Glass Works
84
+ 0.80 EXXON CORP <- Exxon Research and Engineering Co.
85
+ 0.69 FORD MOTOR CO <- Ford Global Technologies
86
+ ... and five plain matches (Abbott Labs, The Boeing Company, ...)
87
+ not linked: Westinghouse Air Brake Company, Abbott Ball Company, General Electrodynamics Corp, Rockwell Automation
88
+ ```
89
+
90
+ Fifty pairs, $0.0007. Jev knows that 3M is Minnesota Mining and that Westinghouse Air Brake is
91
+ not Westinghouse Electric. It can only judge pairs that blocking proposes, though: "3M Company"
92
+ shares no letters with "Minnesota Mining & Mfg", so it was found only because the
93
+ `exact:state` pass paired firms within a state.
94
+
95
+ ## Benchmarks
96
+
97
+ Five public datasets with known matches, run end to end (blocking, judging, resolving) on
98
+ 2026-09-18 with Jev 1.13 through OpenRouter. jlink used its default threshold of 0.5 everywhere.
99
+ Nothing was tuned on the answers. Each string baseline, by contrast, was given the single
100
+ threshold that maximizes its F1 on the answers, so the baseline column is an upper bound on
101
+ what that method can do.
102
+
103
+ | Dataset | Records | jlink precision / recall | jlink F1 | Best string baseline F1 | Pairs judged | Cost |
104
+ |---|---:|---:|---:|---:|---:|---:|
105
+ | Firms: NBER patent assignees to Compustat | 4,585 x 2,488 | 0.90 / 0.62 | **0.73** | 0.69 | 45,567 | $0.62 |
106
+ | Publications: DBLP to ACM | 2,616 x 2,294 | 0.99 / 1.00 | **0.996** | 0.975 | 26,146 | $0.45 |
107
+ | Products: Abt to Buy | 1,081 x 1,092 | 0.90 / 0.93 | **0.92** | 0.86 | 10,796 | $0.22 |
108
+ | Software: Amazon to Google | 1,363 x 3,226 | 0.60 / 0.74 | **0.66** | 0.64 | 13,610 | $0.22 |
109
+ | People: FEBRL4, synthetic typos | 5,000 x 5,000 | 1.00 / 0.92 | 0.96 | **0.998** | 50,000 | $0.97 |
110
+
111
+ The string baselines are best-match Jaro-Winkler and best-match TF-IDF cosine; the table shows
112
+ the better of the two. Exact matching after normalization scores 0.26, 0.41, 0.00, 0.00 and
113
+ 0.22.
114
+
115
+ What the table says:
116
+
117
+ - **Where names carry meaning, jlink wins without tuning.** On firms, products and publications
118
+ it beats string similarity that was handed its best threshold.
119
+ - **Where records differ only by typos, you do not need it.** FEBRL4 is synthetic person data
120
+ with character-level corruption, and TF-IDF cosine is nearly perfect there and free. jlink
121
+ made no false links (precision 1.00) but was too cautious at 0.5; at a threshold of 0.3 its
122
+ F1 is 0.98.
123
+ - **The firm benchmark has a ceiling that no name-based method can pass.** A third of the NBER
124
+ crosswalk's links are ownership facts with nothing in common in the names ("Homogeneous
125
+ Metals Inc" to "United Technologies Corp"), so blocking can propose only 67% of true links.
126
+ jlink found 93% of those. (A sliver of its error is the benchmark's: 11 Compustat names appear
127
+ under two IDs, which accounts for 14 of jlink's 331 false links.)
128
+ - **Amazon to Google is hard for everyone**, because listings differ in version and edition
129
+ details that the records often omit.
130
+
131
+ The firm rule was revised once, after reading the errors of a 300-record pilot, as a user
132
+ would: the first draft did not tell Jev that `CPY` means Company in these data. That changed F1
133
+ by two points. The final rule is two sentences: *"Ignore legal-form suffixes (Inc, Corp, Co,
134
+ CPY, Ltd, GmbH, N V). A subsidiary or division counts as its parent company."*
135
+
136
+ **Are the probabilities calibrated?** Roughly, and it depends on the data. On the firm
137
+ benchmark, pairs scored above 0.8 were true matches 95 to 98% of the time and pairs scored
138
+ below 0.2 were true 0.1% of the time, but the 0.5 to 0.8 band was overconfident (mean 0.64,
139
+ true 39% of the time). On FEBRL4 Jev was
140
+ underconfident: pairs in the 0.2 to 0.5 band were true matches 66% of the time. Treat `p` as a
141
+ strong ranking and check the middle band with an audit sample before using it as a literal
142
+ probability.
143
+
144
+ **Speed.** The firm run judged 45,567 pairs in 176 seconds (259 pairs a second, 64 calls in
145
+ flight, median latency 214 ms, one retry). Blocking 100,000 by 100,000 records takes about four
146
+ minutes and 1 GB on an M3 Ultra; blocking time grows roughly with the square of the data, so
147
+ beyond that size add an `exact` pass on a field such as state or year to split the problem.
148
+
149
+ Reproduce everything: `uv sync --group bench`, `uv run python bench/prepare.py`, then
150
+ `uv run python bench/live.py nber-firms --budget 1.00`. Baselines and data provenance are in
151
+ `bench/BASELINES.md` and `bench/FIRM_DATA.md`.
152
+
153
+ ## How it works
154
+
155
+ 1. **Block.** Comparing every record with every other is wasteful, so jlink first proposes
156
+ candidate pairs on your machine, at no cost. By default each left record is paired with its
157
+ ten nearest right records by character n-grams. Add passes for what n-grams miss:
158
+ `jlink.block.initials("name")` pairs "IBM" with "International Business Machines", and
159
+ `jlink.block.exact("state")` pairs everything within a state.
160
+ 2. **Judge.** Each candidate pair goes to Jev with your rule. Pairs whose fields are identical
161
+ after normalizing case, accents and punctuation are accepted without a call. Likelier pairs
162
+ are judged first, so if a budget runs out it is the long shots that go unjudged.
163
+ 3. **Resolve.** Choose links from the probabilities: `one-to-one` (the default; the best
164
+ overall assignment with no record used twice), `many-to-one`, `one-to-many` or
165
+ `many-to-many`, with a probability threshold and an optional margin over the runner-up.
166
+ `result.relink(...)` tries other rules without paying again.
167
+ 4. **Audit.** `result.audit_sample(200)` draws pairs across the probability range, links and
168
+ non-links alike, with both records side by side. Label them in a spreadsheet, then
169
+ `jlink.evaluate(labeled)` reports precision, recall and calibration.
170
+
171
+ ```python
172
+ linker = jlink.Linker(
173
+ entity="firm", on=[("conm", "assignee"), "state"], definition="...",
174
+ blockers=[jlink.block.ngrams(("conm", "assignee"), k=10), jlink.block.initials(("conm", "assignee"))],
175
+ )
176
+ linker.estimate(compustat, patents, left_id="gvkey", right_id="assignee_id") # blocking only, no API calls
177
+ # {'left': 4585, 'right': 2488, 'pairs': 45567, 'dollars': 0.6316, 'seconds': 227.8}
178
+ # (the real run on these data cost $0.62 and took 176 seconds)
179
+
180
+ result = linker.link(compustat, patents, left_id="gvkey", right_id="assignee_id", budget=2.00)
181
+ strict = result.relink(threshold=0.9, min_margin=0.3) # no new calls
182
+ panel = strict.merged() # both tables side by side, plus p
183
+ result.save("linkage/") # links.csv, scores.csv, settings.json
184
+ ```
185
+
186
+ ## Checking the links
187
+
188
+ ```python
189
+ sample = result.audit_sample(n=200)
190
+ sample.to_csv("audit.csv", index=False) # fill in is_match with 1 or 0, then:
191
+
192
+ ev = jlink.evaluate(pd.read_csv("audit.csv"))
193
+ print(ev.summary())
194
+ print(ev.to_markdown()) # a table for the appendix
195
+ ```
196
+
197
+ The sample is stratified by probability, so the uncertain middle is covered and not only the
198
+ easy ends, and the estimates are weighted back to all judged pairs. Recall is measured among
199
+ candidate pairs. A true match that blocking never proposed is invisible to the audit, so widen
200
+ blocking (a larger `k`, an extra pass) and see whether new links appear.
201
+
202
+ ## Command line, Stata and R
203
+
204
+ ```bash
205
+ jlink estimate compustat.dta patents.csv --on conm=assignee --on state
206
+ jlink link compustat.dta patents.csv --on conm=assignee --on state --entity firm \
207
+ --define "A parent company and its subsidiary are different firms." \
208
+ --left-id gvkey --right-id assignee_id --block ngrams:conm=assignee:10 --block initials:conm=assignee \
209
+ -o links.csv --scores scores.csv --report report.md
210
+ jlink audit scores.csv --left compustat.dta --right patents.csv --on conm=assignee -n 200 -o audit.csv
211
+ jlink evaluate audit.csv --markdown
212
+ ```
213
+
214
+ The Stata and R wrappers are single files in this repository, not part of the Python package: copy
215
+ `stata/jlink.ado` and `stata/jlink.sthlp` to your personal ado directory (`sysdir` shows it), and
216
+ `source()` `r/jlink.R`. Both call the installed command, so install the package first.
217
+
218
+ macOS ships a Java tool at `/usr/bin/jlink`. If `jlink` opens a Java prompt, use `jev-link`,
219
+ which is the same program, or `python -m jlink`.
220
+
221
+ ```stata
222
+ use compustat, clear
223
+ jlink using patents.dta, on(conm=assignee state) entity(firm) leftid(gvkey) rightid(assignee_id) ///
224
+ define("A parent company and its subsidiary are different firms.") saving(links.dta)
225
+ ```
226
+
227
+ ```r
228
+ source("r/jlink.R")
229
+ links <- jlink(compustat, patents, on = c("conm=assignee", "state"), entity = "firm",
230
+ left_id = "gvkey", right_id = "assignee_id")
231
+ ```
232
+
233
+ ## Limits
234
+
235
+ - These are a model's judgments. Audit a sample before you rely on the links.
236
+ - Jev can only judge pairs that blocking proposes. If the names share nothing, add a pass that
237
+ brings the pair together some other way (`exact` on state, year or industry).
238
+ - Jev reads the fields you give it and nothing else. It does not look anything up, and what it
239
+ knows about firms stops at its training data.
240
+ - Repeated calls return nearly but not exactly the same probability (within 0.03 in our tests).
241
+ Saved scores make results exact: keep `scores.csv` with your replication files.
242
+ - The default model ID is an alias for the latest Jev. Pin one with `model=` or `--model`
243
+ (for example `typesafe/jev-1.13` on OpenRouter) and report it; `result.methods()` does.
244
+ - The Stata and R wrappers were run on macOS against Stata 19.5 and R 4.5.1. They do not
245
+ support Windows yet.
246
+
247
+ ## Development
248
+
249
+ ```bash
250
+ uv sync --group bench && uv run pytest # 279 tests, offline, no key; Stata and R tests skip if absent
251
+ ```
252
+
253
+ `SPEC.md` is the design contract the modules were built against. `src/jlink/core.py` is the
254
+ Jev client (two backends, retries, cache, cost meter), shared verbatim with
255
+ [jgrep](https://github.com/keltokhy/jgrep), which is grep with a description in place of a
256
+ pattern.
257
+
258
+ MIT license. The benchmark datasets keep their own terms; see `bench/FIRM_DATA.md`.