API Findings
Base URL & Auth
https://wims-api.winglegroup.com
— PHP 7.4 / Symfony / API Platform (Hydra / JSON-LD)
Key Entity Counts
Existing Relationships (partial coverage)
The API already exposes relationship fields, but they are mostly empty — e.g. patent.companiesAmount = 0 for the vast majority of records:
Company.patents/Company.trademarks— patents & trademarks linked to a companySparsely populatedProduct.manufacturers/Product.brands/Product.patents— company, brand & patent links on productsPartially populatedBrand.ownerCompanies— companies owning the brandTrademarkItem.holders → TrademarkHolder.name— free-text only, no FK to CompaniesPatentApplicant.applicant → Applicant.name— free-text only, no FK to Companies
Core problem: TrademarkHolder.name = "Philip Morris Products S.A." and PatentApplicant.name = "Philip Morris Products Sa" both carry no foreign key to the Companies table. This pipeline's entire purpose is to bridge that gap with confidence-scored matches.
Architecture
Matching Pipeline
- Companies (13K) + DBAs + reg numbers + URLs
- TM Holders (4.5M unique names)
- Patent Applicants (1.5M unique names)
- Products (79K) + brands + manufacturers
- Brands (17K) + ownerCompanies
- Strip legal suffixes (Inc, LLC, S.A., Ltd, GmbH…)
- Lowercase + remove punctuation / diacritics
- Tokenize + sort tokens
- Build inverted index of companies by normalized name
- Exact match on normalized name
- DBA / alias match
- Registration number match
- Domain / URL match
- Token sort ratio ≥ 85 (fuzzball)
- Longest common prefix matching
- Bigram / trigram overlap score
- Dedupe & rank candidates per entity
- Ollama (local) — llama3.1:8b or mistral:7b
- Prompt: "Are these two names the same company?"
- Context injection: country, address, category
- Only run on unmatched or borderline candidates
- Tables: entity_matches, review_queue
- JSON exports by connection type
- Manual review queue for conf < 0.75
Connection Types (priority order)
TrademarkHolder.name, country →
Target: Company.name, .dbas, .registrationNumber
Applicant.name, country →
Target: Company.name, .dbas, .registrationNumber
TrademarkItem.name →
Target: Product.name, Brand.name
Product.manufacturers, Product.patents
Applicant.name ≈ Holder.name
Brand.ownerCompanies + TM name →
Target: cross-entity brand mapping
Technical Stack
- RuntimeNode.js v20+required; async streams for large dataset pagination
- StorageSQLite (better-sqlite3) — zero infrastructure, handles 10 M+ rows, ships everywhere
- Full-text searchSQLite FTS5— built-in trigram search; replaces pg_trgm with no server required
- Fuzzy matchingfuzzball (JS port of RapidFuzz) — token_sort_ratio, partial_ratio
- NormalizationCustom module— no extra deps; handles legal suffixes, Unicode, transliteration
- AI matchingOllama (local LLM) — llama3.1:8b or mistral:7b; free, private, runs offline
- Vector (Phase 2)Ollama embeddings + in-memory cosine— deferred until dataset outgrows in-process approach
- HTTP clientNode.js built-in https— zero dependency footprint
- Configdotenv— reads .env credentials
Why not PostgreSQL / Elasticsearch? The company index is only 13,739 records — SQLite FTS5 handles all lookups in milliseconds. External services add deployment complexity with no measurable gain for a pipeline tool.
Why Ollama over cloud AI? All data stays on-premises, zero API cost, works fully offline. llama3.1:8b fits in 8 GB VRAM or runs on CPU.
Matching Logic Detail
Name Normalization
Applied uniformly to every entity name before any comparison:
"Philip Morris Products S.A." → lowercase: "philip morris products s.a." → remove suffixes: "philip morris products" → remove punct: "philip morris products" → token sort: "morris philip products" → stored as normalized_name
Legal suffix strip list:
inc llc corp ltd limited
sa s.a. plc p.l.c. gmbh
ag bv nv srl sas
pty co company international
group holding holdings
Confidence Scoring Model
- Same country match →
× 1.05 - Different country →
× 0.90 - Matched via DBA / alias →
× 1.02
Filtering Strategy for Large Datasets
Rather than downloading all 4.5 M TM holders row-by-row:
-
1Stream unique holder names (deduplicated in SQLite as they arrive) — reducing ~4.5 M rows to ~1 M distinct strings.
-
2For each unique name, run the matching pipeline against the 13,739-company index (held in memory / FTS5).
-
3Write matches to SQLite; skip non-matching holders — they belong to entities outside the WIMS company scope.
This reduces the effective working set from 4.5 M raw records → ~1 M unique applicants / ~1 M unique holders with a single streaming pass.
Key Risks & Mitigations
| Risk | Impact | Mitigation |
|---|---|---|
| API rate limiting | High — could block data collection entirely | 100 ms delay between pages; exponential backoff on HTTP 429 |
| Token expiry (1 h TTL) | Medium — pipeline breaks mid-run | Auto-refresh via refresh_token before each batch request |
| Name ambiguity (139 "Philip Morris" variants found) | High — wrong company linked | Always surface top-3 candidates with scores; human review for confidence < 0.75 |
| Fuzzy false positives (e.g. "BAT" → wrong company) | Medium — pollutes match quality | Short names (< 5 chars) require stricter threshold (≥ 0.95) |
| Ollama model hallucination | Low — minor score distortion | AI used as tie-breaker only; never sole basis for a high-confidence match |
| Large dataset memory pressure | Low — OOM crash on weak machines | Stream and process in chunks of 1,000; write to SQLite incrementally |
Summary
Node.js + SQLite + fuzzball + OllamaNext Implementation Steps — post v2 TM run · 2026-06-01
TM→Company done (no AI): 407,304 matches · 76.4% high-confidence · avg 0.9224 · pharma false-positives eliminated. Stats report: stats-report.html
Step 1 — Patent Applicant Matching (match:patents)
Goal: run the same pipeline (exact → DBA → LEI → fuzzy → transliteration) against the 1,479,130 unique patent applicant names. The patent_applicants table is currently empty — this is the highest-leverage remaining work.
- Collect unique applicant names via
/api/patents/applicantspagination - Normalize + detect script (reuse TM holder pipeline)
- Run exact → DBA → LEI → fuzzy → transliteration in order
- Write results to
entity_matcheswithsource_type = 'patent_applicant' - Expected volume: ~50–150K matches at similar precision to TM run
Step 2 — Expand Transliteration Coverage
Goal: 1,005,798 non-Latin TM holders (22% of total) currently match at <2%. Extending transliteration to four remaining high-volume scripts is the single highest-leverage precision improvement short of AI.
| Script | Holder Count | Current Match Rate | Approach |
|---|---|---|---|
| Arabic | 102,471 | 0.05% | ALA-LC romanization table or any-Latin ICU transform |
| Thai | 103,187 | 0.20% | Royal Thai General System transliteration |
| Hebrew | 5,221 | 0.02% | SBL / ISO 259 romanization |
| Hangul (Korean) | 204,461 | 0.02% | Revised Romanization of Korean (RRK) |
Estimated yield: 1–5% match rate across these scripts → 4,000–20,000 additional high-confidence links.
Step 3 — Company Deduplication
Goal: upstream API contains duplicate company records (Nestlé ×2, Philip Morris ×3, PepsiCo ×2, JTI ×2). These inflate match counts and would create double-links on API write-back. Must be resolved before any export.
- Query
entity_matchesfor companies sharing the samenormalized_name— identify canonical IDs - Merge match rows: reassign
target_idof duplicates to the canonical ID - Flag duplicates in a
company_duplicateslookup table for downstream use - Do not delete from the companies table — API IDs may be load-bearing
Step 4 — LEI Bridge Expansion
Goal: only 74 of 13,762 companies have a confirmed LEI via Wikidata. The GLEIF golden copy (3,075,680 records) is already loaded — a direct name-match pass between lei_records.legal_name and companies.name can confirm LEIs for hundreds more.
- Run normalized-name exact + fuzzy match between
lei_recordsandcompanies - Store confirmed LEIs in
companies.lei(new column) - Re-run the LEI bridge step against TM holders — expected uplift from 54,627 toward 200K+
Step 5 — AI Scoring Pass (Ollama)
Goal: 88,543 matches sit in the uncertain band (0.60–0.89). AI scoring should elevate true positives and discard false positives in this band — it was designed as a tie-breaker, not a primary matcher.
- Populate
ai_queuefromentity_matches WHERE confidence BETWEEN 0.60 AND 0.89 - Priority ordering:
priority = 1 - confidence(most uncertain first) - Run
run:ai-queueagainst Ollama (llama3.1:8b) — inject country + category context - Update
entity_matches.confidenceandmatch_methodfor confirmed/rejected entries - Run steps 1–4 above first — more stable input = better AI signal
Step 6 — API Write-back (Export)
Goal: push 311,208 high-confidence (≥0.90) TM→Company links back to the WIMS API to populate the empty TrademarkHolder → Company foreign-key relationships.
- Complete Step 3 (deduplication) first — do not write duplicate target IDs
- Batch PATCH requests to
/api/trademark_holders/{id}withcompanyFK - Rate-limit to 100 ms/request; auto-refresh token on 401
- Log each write; support
--dry-runmode for verification - After TM write-back: repeat for Patent→Company once Step 1 is complete