The opportunity model
One rule, applied identically on every screen that ranks anything: a code an account imports is Whitespace, Defend or Displace depending on whether you sell it and whether you already supply it here. Everything else — the plays, the coverage verdicts, the disqualifiers — decides how loudly to say so.
The bucket rule
Given a code an account buys, what kind of opportunity is it?
Flagged rows are ranked last but never hidden. Hiding them is how a bad row goes unnoticed — which is exactly how the defects below survived as long as they did.
Named plays, not scores
A composite score would be a number nobody can argue with and nobody can act on. Each play is a named, checkable claim about the shape of the demand instead.
| Open field | No incumbent holds 40%, across five or more suppliers. Fragmented supply means a low switching barrier. |
| Sole source | One supplier holds 85%+ of material, repeating spend. The buyer carries concentration risk; lead with second-source. |
| Multi-prospect | Three or more covered accounts buy this code. One product line, several doors. |
| Consolidating | The leading incumbent has gained 10+ points of share in the last six months. The window is closing. |
| This supplier covers a large share of the account's entire external procurement across many codes — the signature of captive supply rather than a contested market. Ownership is not in the data; verify before treating it as addressable. | |
| Low attribution | Under half this code's spend has a named supplier. The shares shown describe a minority of the real market. |
The last two are disqualifiers: they sort a row to the bottom and exclude it from every “clean addressable” total, but leave it on the page.
Coverage is a verdict, not a score
An extract that cannot support buy-side analysis is marked as such rather than being silently ranked low. Loading the current split… Only usable accounts are counted in any addressable total anywhere in the product.
Defects found the hard way
Each of these was live, app-wide, and inflating real numbers. They are recorded because each one still constrains how a figure may be read.
group_key() was being called on buyer_family and supplier_family, which are cluster ids, not display names. Sika, Allnex, PPG and Sasol buying from their own regional subsidiaries read as contested third-party business. Fixed by resolving display names before computing group keys, in scripts/opportunity/loading.py.
group_key() fell back to the whole family key when the distinctive root was under four characters, so “PPG Industries” never matched “PPG Cieszyn”. Threshold lowered to three. It cannot go to two: MS and PT are legal-form prefixes and would collapse every Indonesian and Indian company into one group. Two-character brands still slip through, which is why isSameCompany() in lib/contract.ts runs a second, independent check on display names.
Spanish and Portuguese placeholders for “not available” were being treated as a company name across 10,729 rows, making an absence the fourth largest incumbent in the shortlist. Fixed at source with a sentinel vocabulary in pipeline/entity_resolution.py — an unnamed counterparty is reported as undisclosed value, never ranked as a company.
A code carrying products from several families was counted once under each, so summing families gave 3.6× the real total and ranked RHENODIV® second on a figure only 8% attributable to it. Split into reach (overlaps, never sum) and exclusive (codes where the family is the only Lanxess option). Both are shown, labelled, on /lanxess.
Limits worth stating plainly
Customs declarations record imports. Domestic purchases never cross a border and are invisible, so every figure is a share of observed imported wallet — never total spend, and a small number is absence of evidence rather than evidence of absence.
Ownership is not in the data. Both related-party checks — the pipeline's group key and the app's root-token match — are heuristics on names. They will miss a subsidiary that shares no name with its parent, and they will occasionally join two unrelated companies that do.
Coverage is by-catch. The prospect index sees only buyers whose extracts happened to be pulled for somebody else, so a supplier's total there is a fragment of their real business and must never be presented as revenue.