Over-combined, under-combined.
Thirteen businesses shared one identity because their domains had expired. Duke's lemur centre merged with its art museum. Half the pool had no handle at all. A day spent rebuilding entity resolution around one idea — a URL tells you who owns a page, not who a company is.
Entity resolution has exactly two ways to fail, and they pull in opposite directions.
Over-combination is merging rows that are not the same thing. It is the expensive one: two records go in, one comes out, and the addresses, phone numbers and history of the loser are gone or wrong. Under-combination is failing to merge rows that are the same thing. It is the embarrassing one: your dataset says a company appears four times, and anyone who knows the company can see it.
Every knob you can turn trades one for the other. Loosen the matcher and you over-combine; tighten it and you under-combine. The only real way out is to stop treating it as one dial — which is most of what this post is about.
Here is where OnlyData actually stood yesterday morning, on 32,900 active business rows:
No entity layer at all. A company_domains table where every row was an unlabelled string. And a UNIQUE (domain) constraint which — I will get to this — made it structurally impossible to store the only web presence half the dataset had.
The trap gallery
These are not hypotheticals. Every one is a real collision found by asking the database a single question: which URL identities are claimed by more than one business? That question returned 187 groups. Most were real duplicates. The interesting ones were not.
→ one entity
Goodfellows Bar
Sweet Memories Bakery
Freak Alley Gallery
Compass Surgical Partners
Dolly's Vintage … +8
Their domain expired.
It is parked for sale.
13 businesses with
no website, not one
business with 13 names.
host_kind = 'parked' now; identity NULL; never evidence.nasher.duke.edu → duke.edu
gardens.duke.edu → duke.edu
library.duke.edu → duke.edu
one entity
"Duke Lemur Center"
nasher.duke.edu Nasher Museum
gardens.duke.edu Duke Gardens
library.duke.edu Libraries
4 entities, parent_apex
= duke.edu → sibling edges
efcncadmin.wixsite.com
→ both are Wix
aws.amazon.com
cloud.google.com
→ both are tenants
= one auto shop
efcncadmin.wixsite.com
= one church
aws.amazon.com → amazon.com
Amazon runs that itself.
<them>.<suffix> — the public suffix list's private section — so the subdomain is the tenant. Amazon and Google run aws. and cloud. themselves. Nothing in the URL distinguishes these. It has to be a list.Turner Creek Elementary
Panther Creek High
Carpenter Elementary
Green Hope Elementary
West Lake Middle … +2
→ one school
…schoolwires.net/turnercreekes
…schoolwires.net/panthercreekhs
…schoolwires.net/carpenteres
…schoolwires.net/greenhopees
→ 8 schools
→ facebook.com/joesdiner
stored ✓
The barber shop
→ facebook.com/thebarber
rejected — duplicate key
17,293 rows, no handle
INDEX (identity_key)
facebook.com/joesdiner
facebook.com/thebarber
two identities, one host
145 social/directory pages
now stored and matchable
UNIQUE (domain) asserts that a domain belongs to exactly one company — true for domains someone bought, and false the instant two businesses each have a Facebook page. The second one could not be saved at all. With 52.6% of the pool having no domain, that constraint was silently discarding the only identifier half the dataset had."Engineer @ NVIDIA"
"MLE @ Hugging Face"
"Founder & CEO @ Sturdy"
od_team_links rows: 0
invisible to every join
kaiyu-xie → nvidia
githubnemo → hugging-face
steve-hazelton → sturdy
joao-gante → deepmind-google
axiom A12 enforces it
The one idea that fixed most of it
Every trap above is the same mistake wearing a different hat: treating a URL as an identity when it is really a statement about ownership.
So URLs get sorted onto a ladder. Not "is this a domain" but who controls this page — which then determines how much the URL is allowed to prove.
owned_primary — their front doorowned_other — theirs, but not the front doorowned_social — a page they controllisted — true, and not theirsplatform_root — identifies the platformparked — the domain is for saleAnd the correction that made it right
My first version of this said: only owned domains count as match evidence. Clean, defensible, and wrong in a way that would have thrown away half the dataset. The pushback was immediate and correct:
"kinda — BUT a facebook pathed url or yelp page or instagram profile etc need to be saved correctly and matchable"
The distinction I had flattened is the whole thing. It is never "is this a social URL". It is what exactly is shared:
| Two rows share… | Evidence? | Why |
|---|---|---|
yelp.com | None | Yelp lists everyone. Matching on it merges a whole city. |
yelp.com/biz/joes-diner | Strong | The same page. Two rows pointing at one listing are one business. |
facebook.com | None | 34 businesses in the pool are "on Facebook". |
facebook.com/joesdiner | Strong | One page, one owner. |
walmart.com (by Walmart) | Strong | They are the operator. 29 Walmart store rows needed this. |
walmart.com (by anyone else) | None | A seller on a marketplace is not the marketplace. |
That last pair is the one a URL cannot settle on its own — the same string is a front door or a dead end depending on whose row it is. The company's name settles it, deterministically, with no model call: if the domain's label is the company's name, they are the operator.
Then the rules ran out
With the ladder in place, the clear cases were clear. 140 identities were shared by more than one business, and after the classifier fixes most were genuine duplicates. What was left was a pile of cases where no rule could be right — and this was the second correction I needed:
"we SHOULD have an 'inference' layer to settle unclear merges and look at all evidence to decide if its a merge/hierarchy/keep separate situation and store that decision etc — not all merges will be clear enough for simple rules"
Correct, and I had been avoiding it. Look at what was actually in the leftover pile:
| Shared | The rows | What it is |
|---|---|---|
cityofboise.org | Two parks, a senior centre, a fire station | Four real places on their city's website |
twilio.com | Twilio, SendGrid, Twilio Segment | A company and two acquisitions |
thrivepetcare.com | Three differently-named vet clinics | A rollup. The parent is not a row we hold |
wyndhamhotels.com | A Days Inn and a La Quinta | Two brands, one parent, two states |
harristeeter.com | Two supermarkets, different addresses | Two stores. Not a duplicate |
make.com | Make, Integromat | A rename. One entity across time |
No threshold separates those. "Different addresses" splits the supermarkets but also splits the two parks, which should be split — and merges nothing useful about SendGrid. They need what a human would do: read all the evidence at once and decide.
So there is now an adjudication layer. The important part is not the inference — it is that the verdict is stored.
One standing verdict per candidate, with the evidence it was made from
Four verdicts: merge · hierarchy (with the relationship kind and direction) · separate · insufficient. Precedence is explicit and one-directional: human > model > rule. A human verdict is never touched by a later pass.
Without the ledger, every run re-decides from scratch, a human correction is silently overwritten by the next pass, and nothing can explain why two rows are merged. The evidence bundle is frozen at decision time on purpose — the source rows change, and a verdict you cannot audit against what was actually seen is a verdict you cannot trust.
It decides; it does not act. Applying is a separate, reversible step: hierarchy becomes a typed edge, separate becomes an explicit not_related, merge becomes dated entity membership. No business row is edited or deleted, ever.
And it is allowed to say it does not know. insufficient is a first-class verdict, because a model forced to choose will choose, and a forced merge is the expensive kind of wrong. 20 of 141 verdicts came back insufficient — including twelve that named a real employer the pool simply does not hold yet, which is now a to-create list rather than a silent miss.
What it actually caught
The headline cases are the best illustration, because my regex got four of them confidently wrong first and the reasoning is checkable:
| Headline | Regex said | Adjudicated |
|---|---|---|
@pytorch @facebook | employed at PyTorch | employed at Facebook — PyTorch is the project |
ex @datadog @elastic @arduino | current staff at Datadog | separate — "ex" is the whole meaning |
Maintainer @vllm-project | employed at vLLM | separate — maintainership, not employment |
AI-driven developer @ Tokyo | employed at Tokyo | separate — that @ is a location |
Getting good one line at a time | employed at "a time" | separate — names no organisation |
Core maintainer of PyO3 and member of @pydantic | employed at Pydantic | separate — and he probably is; the sentence doesn't say so |
That last row is the one I want to defend, because it is the one that looks like a miss. David Hewitt does work at Pydantic. The headline does not say that, and a pipeline that reads "member of" as "employee of" will be wrong about the open-source contributors it meets far more often than it is right. The honest answer is to not assert it from this evidence.
A merge is not the only answer
The deeper fix is that "are these the same?" was the wrong question to only have one answer to. Three things were being forced through one decision:
- Identity — are these rows the same real-world thing? Stored as entities with dated membership, so un-grouping is recoverable rather than destructive.
- Relationship — are they different things that are connected? Stored as typed, directional, evidenced edges. A shared domain with disagreeing names is now an edge, not a merge.
- Grouping — do I want to look at them as one? Stored as projections, which change no data at all.
Thirty-six Starbucks in Charlotte made this concrete. They are 36 businesses with 36 addresses. "Starbucks in Charlotte" is a genuinely useful thing to look at and is not a thing that exists — it has no address, no phone, and its revenue is not any member's revenue. Collapsing the rows to get the view would delete 35 real locations; refusing the view to protect the rows makes the dataset harder to use than it needs to be. As a projection it costs nothing: every-location shows 36, brands-as-one shows 1, and neither had to destroy the other's answer.
An aggregate is not always valid under a grouping
If A, B and C each do $1M in revenue, the group does not do $1M — and it does not necessarily do $3M either, because the rows may overlap or double-count. Count and sum survive grouping. revenue, employee_estimate and every modelled score do not: they were computed for a row, and under a different grouping they have to be computed again or withheld.
So each attribute declares how it behaves — sum · max · min · primary · distinct · remodel · never. A projection that cannot honestly aggregate something shows nothing rather than a confident wrong number.
Where the techniques actually are
Entity resolution is a 60-year-old field and it is worth saying plainly which parts of the literature we are and are not using.
What we adopted
- Blocking on a computed key, not a raw field. Comparing 32,900 rows pairwise is 541 million comparisons. Blocking on
identity_key— the classified URL identity rather than the raw domain string — is what makes the pass cheap, and it is also what makes it correct, because the classification happens before the blocking rather than after. - Evidence with weights, not boolean rules. A shared owned domain scores 0.95; a shared Yelp listing 0.8; a shared platform host 0. This is the Fellegi–Sunter instinct — accumulate weighted agreement rather than pass/fail a rule — without yet fitting the weights properly, which is the honest caveat below.
- The explicit negative.
not_relatedis a real stored fact, and there are 190 of them now. Without it an automated process re-examines the same pair forever and eventually merges it by accident. We have the scar: Idaho Central Credit Union and CapEd had to be split back apart after exactly that. - Identity separate from the row, with dated membership. Hub-and-satellite in the data-vault sense, or a Palantir-style ontology object: the entity is a stable id, and a row's membership in it has a start and an end. "Was a member, no longer" is a date, not a deletion.
- The public suffix list, including its private section. This is the difference between
abetterautorepair.wixsite.com(a tenant) andaws.amazon.com(infrastructure), and betweenchccs.k12.nc.us(a district) andk12.nc.us(every school in North Carolina).
What we deliberately have not done
- Transitive closure on match edges. If A matches B and B matches C, connected-components clustering says A, B and C are one entity. Matching is not transitive, and this is the single most common way a dataset silently develops a 400-row mega-entity. Correlation clustering — which can place a negative edge inside a cluster and pay for it — is the right model and we are not there yet. Right now groups are small enough that the ledger and the weak-group holdback stand in for it.
- Trained match weights. The 0.95/0.8 numbers are judgments, not estimates. A tool like Splink would fit them with expectation-maximisation from the data itself and give calibrated probabilities instead of my guesses. That is the clearest upgrade available and it needs labelled pairs — which the decision ledger is now quietly accumulating.
- Embedding-based candidate generation. Cheap recall for the name-only rows, where 17,293 rows with no domain live. Also the easiest way to over-combine at scale, so it needs the ledger and calibrated weights first.
- Bitemporal identity. Membership has
added_at/ended_at, so we know when the record changed. We do not separately track when the world changed. Integromat becoming Make is one entity across time; today that is an edge, not a timeline.
The honest grade
Asked for a real one, so: this is where OnlyData's entity pipelines stand as of this evening, after a day of work on them. The "before" column is yesterday morning.
/admin/resolution so a human can actually outrank the machine. Still B, for an honest reason: no human has used that surface yet, so the precedence is proven in mechanism and untested in practice. Worth noting what the third trigger found on its own — the facebook company slug in our own data is held by a Vietnamese restaurant, which is why the adjudicator refused to treat it as Facebook.A13 (every URL row classified) is at 0. A12 (an employer in a headline needs a link row) went 37 → 12, and the 12 are a named to-create list rather than noise. A7 (at most one primary employer per person) went 41 → 0 — seven of those were duplicate company profiles (moonshot/moonshot-ai, thinking-machines/thinkingmachines, builder-io/builderio), and the remaining 15 turned out to be real joint appointments — "MIT" and "MIT / Databricks" is one person with two genuine primary affiliations, so the axiom was wrong to call them violations and its autofix would have silently demoted half of each. Two axioms now read the decision ledger, which is what stops a permanently-red check nobody can fix.
Still a C, for one reason:
A11 sits at 7,932 violations with a working autofix nobody has pulled the trigger on. Enforcement you decline to run is documentation.What I would do next, in order
Cam read the above and said do all of it, so three of these shipped the same evening. Marked accordingly, because a plan that quietly stays a plan is the thing worth noticing.
- Label pairs from the ledger and fit the weights. Attempted — and it produced a better answer than the one I wanted. The ledger is a deliberately biased sample: a candidate only reaches adjudication when the rules could not settle it, so the observed merge rate is
P(merge | the rules found this hard), notP(same entity | shared evidence). Installing it as a weight would make every clear case look like a coin flip.calibrate-match-weights.mjsnow reports the rates with their denominators and says what each can support, and it changes no constant — a calibration you cannot justify is worse than an admitted guess, because it looks like evidence. The honest next step is labelling ~60 random pairs rather than only the hard ones, or expectation-maximisation over agreement patterns, which needs no labels at all.
It did surface one thing worth the trip: among ambiguous shared-domain candidates, only 11% were merges and 49% were hierarchies. A shared domain is far more often a relationship signal than an identity one, which is the whole bet this rebuild made. - Build the review surface. Shipped.
/admin/resolution, lowest-confidence-first so the queue prioritises itself. An override stores a new row and marks the old superseded, so how a candidate was judged over time survives; a rationale is required, because an unexplained human override is worse than a model's — the model at least recorded its reasoning. "Human > model > rule" is now something a person can actually do rather than a comment in a migration. - Attack the long tail with embeddings behind the ledger. Still the biggest hole, still open. 17,293 domainless rows need cheap recall. Candidate generation is the safe place to be loose, as long as every candidate lands in front of a decision rather than in a merge.
- Replace the hand lists with a fetched public suffix list. Shipped. The PSL already encodes the exact distinction the classifier needed — its private section is a maintained list of companies handing subdomains to customers, which is the
wixsite.com-versus-aws.amazon.comproblem solved by someone else. 3,152 entries against my 34. The fetched list is committed, so a network failure can never silently shrink it, and the refresh diffs against the old hand lists rather than replacing them: 20 were real tenant platforms the PSL does not carry (business.site,substack.com, and the school CMS hosts that caused the eight-schools collision), so they stay as explicit local additions. One entry —sch.uk— turned out not to be a public suffix at all; I had invented it. Thirty more live rows reclassified immediately. - Correlation clustering before groups get big. Open. Today a negative edge inside a candidate group is handled by holding the group back. That stops working the moment a group is large and mostly right.
Three questions I am still deciding
With what I would pick, and why — the recommendations are not neutral.
Should a chain brand be an entity, or only a projection?
Recommendation: only a projection, which is what shipped. "Starbucks in Charlotte" has no address and no revenue of its own, and the moment it becomes an entity something will aggregate to it and be wrong. The cost is that genuine brand-level facts — a franchisor relationship, a national contract — have nowhere natural to live. I would rather add Starbucks the corporation as a real entity with franchisor_of edges than make a per-city brand object.
Should a model verdict apply automatically, or wait for review?
Recommendation: auto-apply, with a reversibility bar. It is how it shipped and I think it is right, but only because of what "apply" means here: hierarchy and separate are additive edges, and merge is dated membership that un-ends. Nothing destroys a row. If applying a merge ever meant rewriting od_businesses, I would hold every one for review — the asymmetry is entirely about whether the mistake is cheap to undo, not about how much I trust the verdict.
Should a directory page (Yelp, Google Places) carry the same match weight as a social page?
Recommendation: no — but I now know I cannot justify a number, and that is the answer. A Facebook page is maintained by the business; a Yelp listing is maintained by Yelp and routinely survives a business closing, changing hands, or being merged into a successor. Two rows on one Yelp listing is weaker evidence than two rows on one Instagram profile, and today both score 0.8.
I went to settle it from the ledger and found there is nothing to settle it with: zero of the 141 adjudicated candidates were shared social or directory pages, because the pool currently holds only 145 such URLs in total and almost none of them collide. So splitting the constant would replace one guess with two. It stays at 0.8, the reasoning is written down, and the measurement is queued behind ingesting more social URLs — which, given 52.6% of the pool has no domain, is work worth doing for its own sake.
The two bugs worth more than the features
Both were silent, both corrupted results while reporting success, and both are the kind that make you distrust everything you measured before you found them.
A paginated read with no ORDER BY returns a different subset every time
The helper that read 37,000 rows used .range(from, from+999) with no sort. PostgREST has no stable order without one, so successive windows overlap and omit rows — each pass read a different subset of the table and reported nothing wrong. It surfaced as two rows flapping between owned_primary and platform_root on alternating runs, because the business-name lookup was built from a page set that sometimes contained Walmart's row and sometimes did not.
Same family as the 1,000-row cap: the read succeeds, returns fewer rows than exist, and tells you nothing. It was in three scripts, including the two that had already built the entity layer — which is why it got rebuilt.
An edge that reads as a sentence can assert the opposite of the truth
company_relationships stores (parent_id, child_id, kind), and the direction lives in the kind name, not the columns. "X division_of Y" means X is a unit of Y. "X jurisdictional_parent_of Y" means X has authority over Y. Opposite directions, same two columns.
I wrote 50 edges the wrong way round and the output looked perfect — "Papa Murphy's division_of Papa Murphy's Overland" is a grammatical, confident, backwards claim. No constraint can catch that; only reading it as English can. The convention is now documented at the top of the module with the live rows as worked examples.
What the day actually produced
(was 10,553)
(was 35)
(was 6)
(was no ledger)
And four projections over the same untouched rows: every-location, institutions-as-one (66 groups), brands-as-one (221 groups, 1,127 rows, nothing merged) and shared-domain-families (80 groups — the districts, the universities, the cities).
The thing I would tell anyone starting this: the schema constraint was the bug. Not the matcher, not the thresholds. UNIQUE (domain) was a perfectly reasonable sentence — a domain belongs to one company — that stopped being true the moment the dataset included businesses whose entire web presence was a Facebook page. It had been silently discarding the only identifier half the rows had, and no amount of tuning the matcher would ever have found it, because the data never arrived.
The data is public.
Entities, relationships, and the projections over them.
Browse the datasets How the axioms work