# How much fuzzy matching did we actually need?

**2026-07-31** — seeded random sample, read-only, nothing written to any database.

## The question

Setu was built around fuzzy entity resolution: a detector, an adversarial council of two model
families, a precision oracle, a fail-closed publish gate. Pointed at the ACRA registry, that
machinery had nothing to do. UENs are unique, so a registry has no duplicates of itself.

GeBIZ was the last plausible place for it to earn its keep. Two independent government datasets, no
shared key, supplier names as free text. If fuzzy matching was ever going to be necessary, here is
where it would show up.

## How I measured it

I pulled all **18,464** GeBIZ award records covering FY2021 to FY2026, reduced them to **6,151
distinct supplier names**, and drew a **seeded random sample of 500** (`seed=20260731`).

The sample matters. An earlier informal figure of 46-out-of-48 came from the first 60 rows of the
datastore, which are insertion-ordered and stacked with large-agency IT and audit contracts —
Accenture, KPMG, NEC, PwC. Clean corporate names, every one. That sample was biased and the number
it produced doesn't reproduce.

Then exact-matched uppercased supplier names against the registry's current and former names, and
went looking for what the misses actually were.

## The number

| | |
|---|---|
| Exact match | **393 / 500 = 0.786** |
| Wilson 95% interval | **[0.748, 0.820]** |
| Matched on a former name | **0** |
| Rescued by punctuation and suffix normalisation | **0** |
| Excluding identifiable foreign and statutory names | 0.840 |

## What the misses are

Not spelling variants. Stripping punctuation, collapsing whitespace and canonicalising
`PRIVATE`→`PTE` and `LIMITED`→`LTD` rescued **none** of the 107 misses. Spot-checks say the entities
are simply absent: `GOURMET PARTNER (S) PTE. LTD.`, `SPLENDID COMMUNICATIONS LIMITED` and
`CLEAN AND GREEN SERVICES` each return zero rows on an unanchored `ILIKE` across all 617,987 entities.

What they turned out to be:

- **People.** `LAUW BOON KHENG`, `CHAN JIGUANG GIFFORD`. Government buys from individuals.
- **Foreign companies.** `FORWARD DATA SL` in Spain, `BANDAR DATO ONN SPECIALIST HOSPITAL SDN BHD`
  in Malaysia, `GOLD STANDARD GROUP SERVICES, LLC`. Never on the Singapore register.
- **Trade and business names.** `CLEAN AND GREEN SERVICES`, `GP WANGSA ENGINEERING`,
  `ALLIED INVESTMENTS LIMITED (T/A ALLIED SECURITY)`. Sole proprietorships register separately from
  companies, and this mirror holds companies.
- **Entities that stopped being Live.** Win a tender in 2021, get struck off in 2024, and you are
  gone from a Live-only mirror by construction.

## Would a fuzzy matcher have helped?

I wrote "they don't join at any similarity threshold" before measuring it, which was a guess
dressed as a finding. So I measured it: for all 107 misses, the best trigram similarity match in
the registry.

| best similarity | misses |
|---|---|
| >= 0.9 | 0 |
| 0.8 – 0.9 | 2 |
| 0.7 – 0.8 | 6 |
| 0.6 – 0.7 | 16 |
| 0.5 – 0.6 | 25 |
| < 0.5 | 58 |

The scan first reported 59 in the bottom band, summing to 108 against 107 misses. The extra row was
the literal string `SET`: the query was issued as `psql -tAc "SET pg_trgm.similarity_threshold =
0.3; WITH ..."`, and psql prints the command tag for the `SET` on its own line unless `-q` is
passed. The parser read that line as a record and scored it 0.0.

Worth recording how that was found, because it took three tries. I first blamed registry names
containing newlines, "fixed" it, and got a byte-identical result — the fix was a no-op, since
`'[\n\r]+'` in a standard-conforming SQL string matches the characters backslash, n and r rather
than newline and carriage return. I then blamed supplier names, and there are none containing a
line break. Only after dumping the raw output did the `SET` tag show up. Two confident diagnoses,
neither checked before being written down, while correcting an earlier claim that had not been
checked before being written down.

The band count above is 58 and the total is 107. Fifteen registry names do contain pipe characters
(`C|LEAN (ANSON) PTE. LTD.`, `MADELENE S | LAW`), which truncates the displayed nearest-match name
for those rows in the raw JSON. It does not affect any similarity score or band count.

So candidates do exist. Here are the closest ones:

| similarity | GeBIZ supplier | nearest registry entity |
|---|---|---|
| 0.833 | `SP CONSULTANCY` | `SP HR CONSULTANCY` |
| 0.800 | `KAK SOLUTIONS PTE. LTD.` | `KARA SOLUTIONS PTE. LTD.` |
| 0.769 | `LEONG HIN SENG CIVIL ENGINEERING PTE LTD` | `SIN SENG CIVIL ENGINEERING PTE. LTD.` |
| 0.762 | `ANTHOLOGY INTERNATIONAL PRIVATE LIMITED` | `ANZ INTERNATIONAL PRIVATE LIMITED` |
| 0.711 | `BLACKPEAK (SINGAPORE) PTE. LIMITED` | `BLACKBERRY SINGAPORE PTE. LIMITED` |
| 0.688 | `TAN BOON KEE` | `TAN BOON KENG` |

Read that table again. `BLACKPEAK` against `BLACKBERRY`. Two different people both called Tan Boon
something. `ANTHOLOGY` against `ANZ`.

Turning the threshold down to catch the residue doesn't recover true matches. It manufactures false
ones. Every candidate above is a different entity that happens to share letters, which is the same
failure the oracle measured independently on the registry itself: 53 flagged pairs checked against
UEN anchors, 53 confirmed false, none confirmed true.

## What this means

The missing 21% is a coverage gap, not a matching problem. Those names have no counterpart in the
registry to match against, so no detector, no council and no extra model family moves the number.
The only thing that would is a wider source.

Even at the one point in this project where fuzzy entity resolution was most likely to be needed, it
wasn't needed. Four in five suppliers join on an exact uppercased string. The rest don't join
because they aren't there.

## What I can't claim

- The mirror holds **Live companies only**, roughly 32% of ACRA's full file. Include struck-off
  entities, business names and sole proprietorships and the match rate goes up. This 78.6% is
  measured against a Live-company registry, not against ACRA.
- I judged those similarity candidates to be different entities by reading them. GeBIZ carries no
  UEN, so I can't prove it. The pairs are printed above so you can disagree.
- The foreign and statutory buckets come from regex classification, so they're approximate. The
  75-name residue still holds foreign and trade names the patterns missed, which makes 0.840 a floor
  rather than a result.
- One sample, one seed, 500 of 6,151. The interval is there so the point estimate doesn't get
  overread.

Raw output: `gebiz-match-rate.json`, `gebiz-miss-decomposition.json`, `gebiz-similarity-check.json`.
