Status: Draft (2026-10-04). A first Industry Match tab is built (section 10); a simpler pivot design is proposed (section 11) and awaiting decisions.
Decided (section 0): POST for pasted text; no LLM, limitations stated openly;
lives in the Company Explorer’s Industry Match tab; description sources;
per-system answers; reading view first then a refining stepper; user choices
are session-only; English-only mode. Still open: chunk size/scoring (the
harness settles it).
Owner: michael.hay@mediumroast.io
Scope: given one company (by name, or by pasted description), return the
industry codes that best describe it, across the registered systems (US SIC,
Japan SIC, EU NACE Rev. 2, ISIC Rev. 4), and help the user reach a conclusion
about them. Out of scope: bulk/batch classification, training or fine-tuning a
model, any non-SIC data.
Related: ic-similarity-search-poc.md sec. 5.8 (silent truncation found) and
sec. 10 (first chunking sketch, which this doc replaces and corrects),
sic-global-search.md, sic-hybrid-search.md, company-dns-ux.md sec. 10.3,
go-duckdb-rewrite.md sec. 1 (single-company, reference-project intent).
Home: the Company Explorer’s Industry Match tab. The Company Explorer
becomes Merged | EDGAR | Wikipedia | Industry Match. The Home page’s
existing Industry Match pill already points at “Industry Match”; it will
land on this tab instead of jumping into the IC Semantic panel. The Merged
card gets a link that opens the tab with the company already loaded.
code field, only recommended (the set),
alternatives and limitations; and evaluation is set-based (any
plausible code in the top k, recall of the plausible set), not top-1
agreement with the filed code.Today the Home page’s Industry Match pill passes a company description to
the semantic search as one string. The embedding model reads only the first
256 word pieces and silently drops the rest. ic-similarity-search-poc.md
5.8 found this with IBM (469 tokens, the quantum/AI paragraph never seen);
the question here is how big the problem is and what to do about it. I
measured it rather than guessing.
Wikipedia lead descriptions for 42 well-known US companies (fetched through
our own GET /V4.0/global/company/wikipedia/firmographics/{name}), tokenised
with the model’s own tokenizer.json (no truncation):
| Measure | Value |
|---|---|
| Median / p75 / p90 / max | 320 / 462 / 481 / 653 tokens |
| Longer than 256 (silently truncated today) | 27 of 42 (64%) |
| Longer than 512 (model’s hard position limit) | 5 of 42 (12%) |
| Mean share of text the model never sees | 22% |
| Tokens per word | about 1.37 |
Caveats: the sample is large caps, which have the longest Wikipedia articles; small and private companies will skew shorter, and EDGAR gives us no description at all. Treat 64% as “most of the companies people will try first”, not as a population statistic.
Wikipedia leads are mostly chronological: origin story first, current business last. Truncation keeps the history and drops the present. That is exactly why IBM matched “Calculating and Accounting Machines” (1911 punch-card tabulators) instead of anything about its current business.
Token length of the text we embed for each classification entry
(embedding_text, a breadcrumb such as “Manufacturing > Electronic Equipment >
Semiconductors”), per system:
| System | Rows | Median | p90 | Max |
|---|---|---|---|---|
| US SIC | 1,005 | 24 | 34 | 57 |
| Japan SIC | 1,460 | 26 | 41 | 67 |
| NACE Rev. 2 | 615 | 28 | 43 | 76 |
| ISIC Rev. 4 | 419 | 28 | 42 | 76 |
So we compare a 100-650 token query against 25-token labels. The literature
calls this the query-document size imbalance and notes that embeddings favour
similarly-sized text. It argues for chunks sized near the target, not
chunks that merely fit the window (the earlier sketch in
ic-similarity-search-poc.md 10.1 proposed ~200 tokens; that is probably too
large, see 4).
The model’s real limits. The all-MiniLM-L6-v2 card says input longer than
256 word pieces is truncated, and that the sequence length used in training was
limited to 128 tokens. The tokenizer config advertises 512 (and ours reports
model_max_length 256 in tokenizer.json), which is why some frameworks accept
longer input and cut it quietly. Quality also degrades as text approaches the
window. So there are two thresholds: a hard one at 256 and a soft one near 128
where the model was actually trained.
(model card)
Long-context alternatives exist and are in our embedding crate. The
fastembed 7.1.0 catalogue includes Nomic Embed Text v1/v1.5 (8192 context),
BGE-M3 (8192, multilingual), GTE-base/large-en v1.5 (8192) and ModernBERT
Embed Large, next to the MiniLM family we use. Our corpus vectors are MiniLM
(vector_all_minilm_l6_v2), but the corpus is only 3,499 short strings that
already sit in the feather as embedding_text, so re-embedding it with another
model is cheap and does not need a new Mediumroast delivery. The endpoints
already take a model parameter. A bigger window removes truncation, but it
does not by itself remove the multi-topic problem (one vector for a company
that is genuinely about several things), and Nomic-style models want
search_query: / search_document: prefixes. (Model list read from the crate
source in the local cargo registry.)
Chunking and pooling. The standard practice is to split, embed each chunk, then aggregate by mean, max or weighted pooling. Mean pooling blends topics; the late-chunking paper (embed the whole text with a long-context model, then pool per chunk) reports about +1.5 to +1.9 nDCG@10 on average over naive chunking on BEIR, but it needs a long-context model and it optimises retrieval of passages that need document context, which is not our shape (our targets are short labels with no surrounding document). (Late Chunking, arXiv 2409.04701; Jina write-up)
Industry-classification systems already built this way. The pattern is consistently two-stage: embedding retrieval of candidates, then a decision step.
Two things to take from this: hierarchical decisions are normal, and production systems put a human or an LLM after retrieval. We have no LLM in V4 and the project is a reference implementation; the human-in-the-loop path is the natural fit (see 5.4 and 6).
The ground truth is itself weak. SEC SIC codes are self-selected by the filer, mainly by primary revenue, and a company gets one code even if it spans several. Domino’s Pizza files under wholesale groceries rather than eating places; the SIC system under-represents modern tech. (The Corporate Counsel) So “does our top result equal the SEC code” is a noisy yardstick, and any accuracy figure we publish needs that caveat.
UX guidance for steppers. Multi-step flows suit tasks that are sequential, where each step builds on the last, and where input needs validation; they are a poor fit for short forms or when users revisit sections often. Progressive disclosure is the principle behind them. (general guidance summary; I could not retrieve Nielsen Norman Group’s own pages through search, so the design discussion in 6 leans on these generic sources and on our own constraint that the fast path must stay one click.)
I ran a throwaway experiment (scratch script, not in the repo yet) to find out whether the chunking strategies in 4 change results before we design around them. 24 companies, US SIC corpus only, the stored MiniLM vectors, scored against the filer-reported SEC SIC code. The “truth” codes were written from memory and are unverified; several (IBM 3570, GE 3600, 3M 3290, Lockheed 3760, P&G 2840, Alphabet 7370) are group-level or absent from the 4-digit US corpus, so those rows could only ever score “same 3-digit group”.
Strategies: trunc256 is today’s behaviour. chunkN_max packs whole sentences up to N tokens (one sentence of overlap), embeds each chunk, takes the best similarity per corpus row across chunks. meanvec averages the chunk vectors. top3mean averages each row’s three best chunk scores.
| Strategy | exact @1 | @3 | @5 | @10 | same 3-digit group @5 |
|---|---|---|---|---|---|
| trunc256 (today) | 1 | 4 | 8 | 8 | 12 / 24 |
| chunk64 max | 3 | 6 | 8 | 9 | 10 / 24 |
| chunk128 max | 2 | 2 | 7 | 10 | 11 / 24 |
| chunk200 max | 2 | 6 | 10 | 10 | 14 / 24 |
| chunk128 mean vector | 1 | 4 | 6 | 8 | 9 / 24 |
| chunk128 top-3 mean | 2 | 4 | 7 | 8 | 11 / 24 |
Chunks per company at 64 / 128 / 200 tokens: 4-14 / 2-7 / 1-4.
What this does and does not show:
ic-similarity-search-poc.md 10.1) to reject averaging.Update (2026-10-04, later): the table above is superseded by a run with
verified labels. My memory-written “truth” codes were wrong for several
companies (Coca-Cola files under 2080, Starbucks 5810, Nike 3021, 3M 3841,
Honeywell 3724, Disney 7990), so the pilot’s absolute numbers were
understated. experiments/company-sic-eval now pulls the filer SIC from the
SEC’s submissions API for 44 companies and sweeps chunk size 64-200 with a
paired bootstrap. Findings:
Update 2 (2026-10-04, N=200): the borderline gain above did not hold.
companies.json now has 200 companies (80 long, 64 mid, 56 short
descriptions; 53 SIC divisions; the 399-company pool is kept in
companies_pool.json). Same sweep, same verified filer SIC:
Consequence for the plan: do not justify chunking by accuracy against
filer SIC. Justify it by coverage and explainability, and measure those with
the hand-judged acceptable sets (any plausible code in the top k, lines of
business covered). Until those exist, chunk size stays at the 128 default and
max-pool stays the scoring; neither is supported by a measured advantage.
Update 3 (2026-10-04): the hand-drafted acceptable sets, now human-reviewed.
All 37 were reviewed on 2026-10-04 (labels_status: "reviewed"); the review
removed 12 codes across 8 companies, mostly financing arms and payment-network
services I had been too generous with (Ford and Caterpillar credit, Visa 7389,
Live Nation ticketing, P&G razors). Rerunning on the reviewed labels left every
conclusion below unchanged (US SIC any@10 still 0.68 vs 0.78; recall@10 moved
0.39 to 0.40 and 0.47 to 0.49). Original draft wording follows.
companies.json now carries acceptable codes (US SIC, ISIC Rev.4, NACE
Rev.2) for 37 companies, status draft. I wrote them from each company’s lines
of business, not from retrieval, and checked that every code exists in its
corpus (draft_labels.py). NACE is listed separately because its four-digit
classes are not always ISIC’s (pharmaceuticals is 21.10/21.20 in NACE, 2100 in
ISIC). Results (any plausible code in the top 10):
| System | truncation | max-pool 128 | max-pool 200 | delta vs truncation (95% CI) |
|---|---|---|---|---|
| US SIC | 0.68 | 0.78 | 0.73 | max128 [+0.03, +0.22]; max200 [0.00, +0.14] |
| ISIC Rev.4 | 0.89 | 0.81 | 0.86 | max128 [-0.22, +0.03] |
| NACE Rev.2 | 0.86 | 0.81 | 0.84 | max128 [-0.16, +0.05] |
Recall of the plausible codes in the top 10 follows the same pattern (US 0.39 to 0.47 with max-pool; ISIC and NACE flat at about 0.56-0.59). Reading it honestly:
draft status), and enough companies labelled (target 80+) to tell a
per-system effect from noise.Conclusion for planning: chunking’s value is coverage and explainability (surface several lines of business, show which sentence drove which code, stop losing the current business), not a large lift in top-1 accuracy. That changes how we should judge it: by whether a reasonable code appears among the candidates and the user can see why, not by matching one SEC code. It also means we need a better evaluation set before tuning anything (see 8).
| # | Strategy | Idea | Notes |
|---|---|---|---|
| A | Truncate | Today. Model sees first 256 tokens | Loses the present business; keep only as the baseline in the harness |
| B | Head + tail | First N and last N tokens | Cheap, but arbitrary; Wikipedia’s tail is not reliably the business summary |
| C | Sentence windows | Pack whole sentences to a target size, one-sentence overlap | Simple, deterministic, explainable; the working proposal |
| D | Topic segmentation | Start a new chunk where adjacent-sentence similarity drops (TextTiling-style) | Chunks follow topics, not length; more moving parts, still deterministic |
| E | Per sentence | Every sentence a chunk | Best fit to 25-token targets; most calls, noisiest on short sentences |
| F | Late chunking | Embed whole text with a long-context model, pool per chunk | Needs a model swap (2, 7); built for passage retrieval, so uncertain fit |
Working proposal: C, target about 100-128 tokens, hard cap 200. Reasons: the model was trained at 128, the labels are about 25 tokens, and the pilot could not tell 64, 128 and 200 apart, so the size should be a parameter that the evaluation harness settles, not a number we hard-code on instinct. A short description (under about 128 tokens) is a single chunk and takes the exact same path as a long one, so there is no special case.
Details to settle:
/V4.0/na/sic/similarity-check meter stays
useful, but its meaning changes from “will be truncated” to “will be split
into N chunks”.Note on the existing hybrid search: its keyword half is a substring match of the query inside class descriptions, which can never match a pasted paragraph. For long text the Combined tab degrades to semantic-only (it already shows a note when keyword finds nothing). The chunked path should be semantic per chunk; any keyword help would have to come from extracting distinctive terms, which is a separate idea and not proposed here.
Embed each chunk, search all registered systems (the existing global
UNION ALL path in crates/sic/src/global.rs), keep the top N per chunk
(N about 10) with the chunk index and similarity.
unique_key. Two candidate scorings, to be
compared in the harness rather than chosen by argument:
ic-similarity-search-poc.md 10.1 over averaging).
Easy to explain, but similarities are not on the same scale across
systems (measured offsets in sic-global-search.md: ISIC about -0.06,
NACE about -0.04 against US).1/(k + rank) to an entry, the same fusion hybrid.rs already
implements. Immune to cross-system scale offsets and rewards entries
that several chunks agree on.
Either way, keep three things per entry for display: best similarity,
support (how many chunks put it in their top m), and the best chunk’s
text as the “matched on” evidence.This is decision 9 in section 0 made concrete: the unit of output is a recommended set, so everything below (primary and secondary, lines of business, confidence) describes how that set is built and shown.
ic-similarity-search-poc.md 5.7), the margin to the runner-up, and
support (chunks in agreement). Show them; do not collapse them into a
made-up percentage.Both, and the split is the proposal: the endpoint is deterministic and structured (chunks, per-chunk candidates, per-system rolled-up answer, confidence signals, evidence) so a script gets the same answer a person sees; the UX is where judgement happens (switching chunks off, picking among close candidates, overriding the sector). No LLM in the loop (decision 2 in section 0).
Sketch only, shapes not final:
POST /V4.0/global/sic/match body: { "text": "...", "options": { chunk_target, top_n } }
GET /V4.0/global/company/{name}/sic-match (resolves description via the merged endpoint, then as above)
data: {
input: { source: "wikipedia|pasted", tokens, chunks: n, truncated: false },
chunks: [ { i, text, tokens, noise: bool, top: [ {unique_key, source_type, sim} ] } ],
systems: { us_sic: { sectors: [...], recommended: [ {code, line_of_business, evidence_chunk, sim} ], alternatives: [...] }, nace_rev2: {...}, ... },
filed: { us_sic: "3571" } | null, // the company's own filing, shown beside the set, never as the answer
limitations: [ ... ],
confidence: { label, best_sim, margin, support },
note: "cannot tell: ..." | null
}
POST for the text is decided (section 0). Remaining API questions: whether the by-name GET is worth having given the UI can call the merged endpoint and then POST (cheap to keep, but it is a second contract to maintain), whether the POST needs the same rate-limit tier as the cheap GETs (it embeds several chunks per call, so it is the most expensive endpoint V4 would have), and a maximum body size.
Without a model that reads and reasons, we are matching meaning by vector
similarity to short labels. We should say plainly where that breaks, in the
response (a limitations array of short codes plus a one-line explanation)
and in the UI (a collapsible “How to read this” panel, and an inline notice
when a specific limitation applies to the current result):
Why this is acceptable: the project is a reference implementation of working with IC and company data, and honest uncertainty is part of the lesson. The response and UI should make “here is what the method cannot know” easy to find, and the guided mode (section 6) gives the user the one thing the algorithm lacks, which is their own judgement.
What we are designing around: input is ambiguous, the correct answer depends on judgement, and the user needs to see why before they trust a code. A static ranked list (today) answers neither. Ideas, from smallest to most novel, not mutually exclusive:
U1. Reading view (explain what the model saw). Show the description with each chunk visibly bracketed and coloured. A marker shows where the old 256-token window would have stopped (the 22% that used to vanish). Click a chunk to see its top codes; click a code to highlight the chunks that support it. Cheap, directly answers the confusion that started this, and is the foundation for everything below.
Company: International Business Machines [479 tokens -> 7 chunks]
+-------------------------------------+ +-------------------------------+
| [1] IBM is an American multinational| | Sector (US SIC) |
| technology corporation ... | | Services - Business ####.. |
| [2] Founded 1911 as Computing-... | | Manufacturing - Industrial |
| ---- old 256-token cutoff ---- | | Codes |
| [5] ... quantum, AI, hybrid cloud | | 7371 Computer programming ...|
+-------------------------------------+ | evidence: chunk 5, 0.62 |
+-------------------------------+
U2. Guided mode (the stepper). The task is sequential and judgement-heavy, which is the case where a stepper earns its place; it is a poor fit when the user already knows what they want. So: a Quick / Guided toggle, Quick being today’s one-click result built on U1, Guided adding steps. Every step has a sensible default so “Next, Next, Next” always works.
(1 Company) -- (2 Segments) -- (3 Sector) -- (4 Codes) -- (5 Result)
^ always reachable; steps are skippable; state kept when you go back
Risks to design for: users revisit earlier steps often (general stepper guidance says they handle that badly), so the stepper must be a non-blocking rail where any step is reachable and changes ripple forward, not a locked wizard; and it must not slow the expert path.
U3. Candidate probing (borrowed from ClassifAI’s follow-up question). When two sectors are near-tied, ask one plain question (“Does this company make the products it sells, or resell them?”) generated from the difference between the candidates’ labels, answered yes/no, which re-weights chunks. Needs curated question templates per near-tie; promising, but only after U1 and U2 prove useful.
U4. Visual alternatives (optional, probably later). A chunk-to-sector-to- code flow diagram makes multi-line businesses legible at a glance, and a side-by-side per-system answer reuses the Compare layout. Nice, not needed to learn whether the approach works.
Fit with what exists. The Company Explorer’s Merged tab already builds the
company description and CIK; the IC explorer shell, calibrated labels and View
JSON modal are reusable. Where it lives is decided (section 0): the
Industry Match tab of the Company Explorer, in the same IC-style shell as
Merged, EDGAR and Wikipedia. Like the other tabs it carries the query when you
switch (type a name on Merged, switch to Industry Match, it loads that
company’s description). Practical consequences: the tab’s first step is the
company lookup (the existing merged endpoint supplies the description and
CIK); the Home > Company > Industry Match pill lands here; and the Compare
and Combined tabs in the IC explorer are not touched. The tab also needs a
paste-your-own-text path, because Wikipedia is not always right or present.
Enrichment worth considering. Show the company’s own filed SEC SIC next to
our match (“filed as 3571; description suggests …”). That frames the
disagreements we saw in the pilot instead of hiding them. EDGAR’s SIC is not
in the 10-x catalog today (SIC data staging is manual, see
v4-deployment.md), so this is gated on data.
Keep MiniLM plus chunking as the default: corpus vectors already match,
the chunking fixes the window, and chunk size can sit where the model was
trained. In parallel, run a bake-off in the harness because the corpus is
cheap to re-embed: MiniLM + chunks, Nomic v1.5 whole-text, Nomic + chunks,
BGE-M3 (also tests non-English), GTE v1.5. Whole-text on a long-context model
removes truncation but not multi-topic dilution, and may not beat chunking;
measure rather than assume. Anything needing prefixes must apply them to both
the corpus and the query path. Model swap also touches binary size and
container image (v4-deployment.md), a real cost for the thin-binary goal.
Before tuning anything. Note (decision 7): the stepper’s user choices are session-only and never stored, so they cannot double as an evaluation set. Tuning chunk size, scoring and model therefore needs a small developer-side set that is separate from the product: public data (the filer SEC SIC) plus a few dozen companies we judge ourselves during development, kept in the experiments folder, never collected from users. Decided (2026-10-04): keep the small hand-judged set alongside filer SIC. Format should be extensible (one JSON record per company: name, description source, filer SIC, acceptable codes per system, notes) so mediumroast.io can later contribute and expand it.
experiments/sic-hybrid-eval pattern into
experiments/company-sic-eval, bring the pilot script in from scratch,
run strategy x chunk size x scoring (max vs voting) x model, with
pass/fail thresholds only after we see baseline numbers.The user asked to see the system in play before labelling more companies, with the end of the stepper delivering 2-5 codes with their hierarchy. So steps 2-4 of the earlier order were built as one vertical slice (not committed).
Built
crates/sic/src/company_match.rs: sentence-window chunker (a port of
experiments/company-sic-eval/chunker.py, byte-for-byte identical chunks on
all 200 evaluation descriptions, parity_check.py), per-system search
(SicCatalog::search_per_system: each system’s own 50 nearest per chunk, so a
system with lower similarities is not crowded out), and the selection rule
choose. 8 unit tests; whole workspace tests and clippy clean.POST /V4.0/global/sic/match (the first POST route). Body {text} or
{chunks:[...]} (re-match the segments you kept, nothing re-split). Returns
input, chunks (each with its own top 3 per system and whether a plain
search would have read it: read/partial/unread), systems
(recommended 2-5 and alternatives, each with the full four-level
hierarchy, similarity, votes and the evidence chunk) and limitations.
Body limit 64 KB (existing layer), text up to 30,000 characters, at most 16
chunks. About 70-145 ms per company on this machine.select_eval.py because it matches the
multi-business idea and scored the same or better.Segments > Codes > Result, every reachable step clickable). Segments is the reading view (each segment, what it alone points at, a marker for the part a plain search would never have read, switch segments off and re-match). Codes has per-system pickers with a 2-5 counter, the hierarchy as a breadcrumb, similarity relative to the system’s best, and the supporting segment. Result shows each chosen code with its section > division > group > class tree; copy as text, View JSON, start over. Home: the Company box is a growable textarea (Enter searches, Shift+Enter adds a line) and the Industry Match pill opens this tab with a name (description fetched) or a pasted description.
What using it showed (and what changed because of it)
nominees_per_chunk). Hitachi now returns five per system, including rail,
lifting equipment and industrial machinery.How good is the automatic set? (live endpoint, 37 reviewed companies, set = 2-5 recommended, pool = recommended + 7 alternatives, the stepper’s menu)
| System | set: any plausible code | set precision | set recall | pool: any plausible | pool recall |
|---|---|---|---|---|---|
| US SIC | 0.62 | 0.21 | 0.31 | 0.76 | 0.46 |
| ISIC Rev.4 | 0.81 | 0.23 | 0.46 | 0.89 | 0.59 |
| NACE Rev.2 | 0.73 | 0.23 | 0.42 | 0.86 | 0.59 |
The unaided set is right about a fifth of the time per code and contains a plausible code in 62-81% of cases; the pool reaches one in 76-89% of cases. So the stepper is not decoration: choosing from the pool is what turns a noisy automatic set into a usable one, and for roughly one US company in four even the pool has no plausible code. Do not describe the automatic output as an answer.
Next
Proposal (from the user). The four-step stepper is too much for a business user. Instead: help the user narrow to a small set of codes in one system (US SIC) first. Then let them choose which target systems they care about (Japan, EU NACE, ISIC), and use the selected US codes to find the matching codes in those systems, by semantic search, keyword search or a combination. The systems are used to search each other, which is tractable and also points at a future crosswalk capability.
Why it is attractive. One judgement in one system instead of four pickers; every target code carries its provenance (“mapped from US 3571”), which is a crosswalk the user can read; the target-system step costs the user one click per system; and the same mapper later serves code-to-code lookups with no company text at all.
experiments/company-sic-eval/crosswalk_eval.py)All use the stored MiniLM vectors for the corpora, plus titles re-embedded on their own. Reviewed sets, 37 companies, “any plausible” = at least one reviewer- accepted code in the returned five.
E1: does code-to-code matching work at all? A label-free structural check: 335 ISIC Rev.4 classes have a four-digit code identical to a NACE Rev.2 class (ISIC 4791 = NACE 47.91). Does the matcher put the identical class near the top?
| Matcher | ISIC to NACE top-1 / 3 / 5 | NACE to ISIC top-1 / 3 / 5 |
|---|---|---|
| semantic, stored breadcrumb vectors | 0.80 / 0.87 / 0.90 | 0.84 / 0.91 / 0.94 |
| semantic, titles only | 0.81 / 0.87 / 0.88 | 0.83 / 0.87 / 0.90 |
| keyword overlap | 0.79 / 0.84 / 0.87 | 0.81 / 0.85 / 0.88 |
| breadcrumb + title + keyword (rank fusion) | 0.80 / 0.86 / 0.89 | 0.83 / 0.90 / 0.92 |
Between systems whose wording is close, matching finds the counterpart about four times in five at rank 1 and nine times in ten within five. The matchers are nearly interchangeable. Misses are partly real (NACE splits some ISIC classes, so “the identical code” is not the only right answer), so this is a floor.
E2: the use case, with a perfect US narrowing (an upper bound). Take the US codes the reviewer accepted for each company, map them, compare with the reviewed ISIC/NACE sets.
| Target | direct: description to target | pivot, best matcher | any / precision / recall |
|---|---|---|---|
| ISIC Rev.4 | 0.81 / 0.23 / 0.46 | semantic titles | 0.92 / 0.32 / 0.63 |
| NACE Rev.2 | 0.73 / 0.23 / 0.42 | semantic titles or breadcrumb fusion | 0.89 / 0.34 / 0.61 |
If the US codes are right, the mapped sets are clearly better than matching the description straight into the target system.
E2b: a person picks from the stepper’s 12-code US menu (simulated). The simulated person picks exactly the menu codes the reviewer accepted, and where the menu has none, the automatic five are used. The menu contained an accepted US code for 28 of 37 companies (76%).
| Target | direct | pivot, semantic titles | pivot, combined query | pivot (3) + direct fill |
|---|---|---|---|---|
| ISIC, all 37 | 0.81 / 0.23 / 0.46 | 0.68 / 0.23 / 0.41 | 0.70 / 0.21 / 0.43 | 0.78 / 0.23 / 0.47 |
| ISIC, the 28 with a right menu code | 0.86 / 0.25 / 0.50 | 0.82 / 0.28 / 0.51 | 0.86 / 0.26 / 0.53 | 0.86 / 0.26 / 0.52 |
| NACE, all 37 | 0.73 / 0.23 / 0.42 | 0.65 / 0.23 / 0.38 | 0.78 / 0.24 / 0.46 | 0.84 / 0.27 / 0.51 |
| NACE, the 28 | 0.75 / 0.24 / 0.45 | 0.86 / 0.31 / 0.50 | 0.93 / 0.30 / 0.56 | 0.93 / 0.31 / 0.58 |
(“Combined query” = the selected codes’ titles joined into one query; “fill” = three mapped codes plus direct matches up to five.)
With the automatic US set and no person involved, pivoting is worse than direct (any-plausible 0.43-0.59 against 0.73-0.81): US errors carry into every system. The pivot only pays off when the US step is good.
Japan SIC has no reviewed sets, so only a spot check: US 3571 Electronic Computers maps to Japan 2841 Electronic circuit board, 7032 Electronic computers and related equipment; US 7372 Prepackaged Software to 3913 Package software services; US 5812 Eating Places to 7611 Eating places. Plausible, unmeasured.
Decision (user, 2026-10-04): the product does not use crosswalk tables. Their licensing is unresolved (11.6), so the server, UI and data directory no longer contain or read them and the downloaded files were deleted. What follows is the measured evidence gathered before that decision, kept because it shows what similarity-only mapping gives up. Section 11.6 describes what was built instead.
The UN Statistics Division tables (ISIC Rev.4 <-> NACE Rev.2; ISIC Rev.3.1 ->
Rev.4; ISIC Rev.3 <-> US SIC 1987) are in experiments/company-sic-eval/
crosswalks/ (official_crosswalks.py loads them; the JSIC Rev.13 <-> ISIC Rev.4
workbook is downloaded but is legacy Excel with no reader installed yet). The
chain US SIC -> ISIC Rev.3 (taken as 3.1) -> ISIC Rev.4 -> NACE Rev.2 covers 989
of our 1,005 US classes. Official mappings are many-to-many: a US class lists
5.2 ISIC counterparts on average, 8.6 NACE.
E3: the similarity mapper against the official tables (“any official counterpart in the top k”; stored breadcrumb vectors, the best matcher):
| Mapping | top-1 | top-3 | top-5 | top-10 |
|---|---|---|---|---|
| US SIC to ISIC Rev.4 | 0.62 | 0.82 | 0.88 | 0.94 |
| US SIC to NACE Rev.2 | 0.68 | 0.83 | 0.88 | 0.95 |
| ISIC Rev.4 to NACE Rev.2 | 0.99 | 1.00 | 1.00 | 1.00 |
| NACE Rev.2 to ISIC Rev.4 | 0.93 | 0.97 | 0.99 | 1.00 |
The hierarchy text matters: breadcrumb vectors beat titles alone (0.88 vs 0.81 top-5 for US to ISIC), and keyword overlap is weak across systems (0.57). Closely related systems map almost perfectly; US SIC, being older and differently structured, maps usefully but not exactly.
E4: official tables versus the similarity mapper, on the reviewed companies (any plausible / precision / recall / mean set size, ISIC target; NACE is the same shape):
| US codes given | direct (no US step) | official, unranked | official, re-ranked by the description (top 5) | similarity mapper |
|---|---|---|---|---|
| perfect set | 0.81 / 0.23 / 0.47 / 5 | 1.00 / 0.31 / 0.94 / 12.8 | 0.92 / 0.41 / 0.69 / 4.6 | 0.92 / 0.32 / 0.63 / 4.9 |
| person picks from the 12-code menu | same | 0.81 / 0.31 / 0.57 / 9.1 | 0.76 / 0.36 / 0.51 / 4.2 | 0.68 / 0.23 / 0.41 / 4.4 |
| automatic five | same | 0.73 / 0.11 / 0.46 / 15 | 0.59 / 0.18 / 0.36 / 4.8 | 0.51 / 0.13 / 0.27 / 5 |
What changes. (1) Use the official crosswalk as the mapper wherever one exists; the similarity mapper is the fallback for unmapped codes and for pairs with no table (and the way to check coverage). (2) The best five come from ranking the official candidates by how well they fit the description: with a perfect US set that is the most precise option (0.41) and keeps 0.92 any-plausible; with a realistic person’s picks it is on par with direct matching on coverage and clearly better on precision (0.36 vs 0.23). (3) With no description (a pure code-to-code lookup) return the official set unranked, ordered by similarity, with its provenance. (4) ISIC Rev.4 is the natural hub: every other system links to it, so any system can be the starting point (the user’s refinement).
One page, two phases, no stepper chrome.
POST /V4.0/global/sic/match for the US phase, restricted to US SIC by
default (a systems field to opt others in). Returns the recommended set, the
alternatives and the chunks as today.POST /V4.0/global/sic/map: { "codes": ["3571","7372"], "from":
"US SIC", "to": ["ISIC Rev. 4","EU NACE","Japan SIC"], "description": "..."
(optional, for the direct fill) }. For each target: 2-5 codes, each with
hierarchy and from_codes. Method: the selected codes’ titles joined as one
query plus per-code nominations, then filled from the description when given
(E2b’s best combination). Code-to-code needs no company text, so this
endpoint is also the future crosswalk lookup.Answers from the user: all other systems are selected by default; one page in two phases; the user can choose the starting system (US SIC by default); and, last, no crosswalk tables: they were downloaded, used in the experiments above, then removed from the server, UI, data directory and disk (see the licensing note below).
crates/sic/src/map.rs maps a chosen set into other systems by similarity
only. Each chosen code nominates its 5 most similar entries in the target system
(using the stored hierarchy-text vectors, which beat title-only vectors). Without a
description they are ranked by similarity to the chosen codes. With one, the top 3
mapped codes (ranked by the mean of similarity to the chosen codes and fit to the
description) are kept and the set is filled to five by matching the description
straight into the target system. Each entry says whether it came from the chosen
codes (basis: codes, with from_codes) or from the description.mapper_variants.py, 37 reviewed companies, a
person simulated as picking the accepted codes from the stepper’s 12-code menu,
falling back to the automatic five): the chosen variant reaches a plausible code
86% of the time for ISIC and 84% for NACE (direct matching: 81% and 76%). When the
menu had a right US code (28 of 37) every variant is above 90%, and 8 nominees per
code ranked by fit reached 96% with precision 0.31-0.33. The official tables did
better (92% with precision 0.41 for a perfect US set), which is the cost of the
decision, but similarity-only is already at or above direct matching.POST /V4.0/global/sic/map {from, codes, to?, description?}; about 180-220 ms
with a description. Doubles as a code-to-code lookup when no description is given.POST /V4.0/global/sic/match takes systems and defaults to US SIC only.static/js/simple-pdf.js, no dependencies, A4, Helvetica, selectable text, automatic page breaks and
“Page x of y” footers) with the company name, the description as entered, the starting system and how
many segments and phrases were matched, then every chosen code per system with its full hierarchy and
where it came from, ending with the not-an-official-classification note. Limits: text in the PDF is
Latin-1 plus common punctuation, so characters outside it (IPA, Japanese script) print as “?”; the
company name comes from the lookup or the name box, else “Company (name not given)”.Why the crosswalk tables were removed: licensing (checked 2026-10-04; not legal advice). Sources:
UN Statistics Division files ISIC4_NACE2.txt, ISIC31_ISIC4.txt and
ISIC-USSIC.csv (the last is the 1993 “International Concordance between the
Industrial Classifications” input table), and Japan’s jsic13_isic4.xls
(soumu.go.jp, Ministry of Internal Affairs and Communications). None of the
files states a licence. The UN’s general copyright page says materials on its
site may not be used, reproduced or transmitted without written permission, with
no dataset or non-commercial allowance, and ISIC texts are marked “Copyright (c)
United Nations”. The Japanese page’s footer reads “All Rights Reserved”; the
Statistics Bureau’s own site publishes under the Government of Japan Standard
Terms of Use v2.0 (compatible with CC BY 4.0), but I could not confirm that those
terms cover this soumu.go.jp file. So: fine for local development, not cleared
for redistribution, and the mapped results the API serves are derived from them.
Release gate: obtain written permission from UN Publications (Rights and
Permissions) and read the soumu.go.jp “Website Information” page, or replace the
tables with ones whose terms are clear (candidates to verify: Eurostat’s
NACE Rev.2 / ISIC Rev.4 correspondence from its RAMON/Metadata server, and the
US Census Bureau’s NAICS-SIC and NAICS-ISIC concordances, which are US government
works).
Still open. The similarity mapper is weaker than the official tables for Japan
SIC in particular (measured before removal: ISIC to Japan top-5 0.71, against 0.88 for
US to ISIC); Japan matches are unverified suggestions and the page says so. Official
tables could return later only with clear terms (candidates to verify: Eurostat’s NACE
Rev.2 / ISIC Rev.4 correspondence, and the US Census Bureau’s NAICS-SIC and NAICS-ISIC
concordances, which are US government works). The sector roll-up and the filed-SIC
comparison remain unbuilt; a live scorer for /map against the reviewed sets is not
written.
The report. A hand-written Hitachi description covering every sector came back “crap”: US SIC returned Hardwood Veneer, Carbon Black and Computer Facilities Management; ISIC returned Repair of consumer electronics and Manufacture of veneer sheets. The text is 140 word pieces, four segments.
Diagnosis. (1) Three of the four segments are not about the business at all (headquarters, founding, headcount, a carbon-neutrality target) and each still nominated its top codes, so they cast junk votes (“carbon neutrality” pulled Carbon and Graphite Products). (2) The one real segment lists the businesses (“data storage”, “power grids and clean energy solutions”, “advanced railway mobility”, “industrial systems”) but was matched as a single vector, which dilutes every item. A short phrase matches a short code title far better than the paragraph around it.
Experiments (selection_variants.py, reviewed sets, set of 2-5): a relevance
gate on whole segments helped US precision slightly but cost ISIC and NACE a lot
(any-plausible 0.81 down to 0.59-0.68), so it is not used. Querying the key
phrases on their own raised US from 0.62 to 0.70 and NACE from 0.73 to 0.78 and
left ISIC about even (0.81 to 0.78). Weighting each vote by its similarity (so a
weak nomination cannot outvote a strong one by repetition) held those numbers and
demoted junk. A similarity floor on segment votes hurt ISIC and NACE and was dropped.
Built. phrases() (mirrored in chunker.py as the spec; identical output on all
200 descriptions): split on commas, semicolons, brackets, “and”, “including”, “such as”,
“via”, “with”, “through”, “across”; drop filler lead-ins (“specializing in”); keep
fragments of 2-7 words with at least two content words; drop founding, headquarters,
headcount and similar. Each phrase nominates its single best code if the similarity is at
least 0.40. Recommended codes rank by summed similarity of their nominations. The match
response carries input.phrases, and an entry says whether a segment or a phrase was
its best evidence. The Segments panel lists the phrases. Live scores on the 37 reviewed
companies: US SIC any-plausible 0.70, ISIC 0.78, NACE 0.78 (set precision 0.21-0.24).
What Hitachi returns now. ISIC: Electric power generation, transmission and distribution (from “power grids”), Passenger rail transport (from “advanced railway mobility”), Installation of industrial machinery (from “industrial systems”), Lifting and handling equipment, plus one junk code (Repair of consumer electronics). US SIC: Electric and Other Services Combined, Railroads, plus three weak ones (Industrial Patterns, Direct Mail Advertising, Computer Terminals). Better, not good: the US set still depends on the person choosing, and “data storage” no longer reaches the top five for US SIC. A person keeps the good ones and drops the rest; the automatic set is a starting point, not an answer.
Stop words: tested, no effect (2026-10-04, stopword_eval.py). Question: does stripping
filler words from the text we embed help? Rust has the stop-words crate (Stopwords-ISO
English, about 1,300 words, and the NLTK list behind its nltk feature). Compared with the
current pipeline (no stripping), on the 37 reviewed companies and on the 200 filer-SIC
companies: NLTK (179 words), scikit-learn (318) and a list learned from our own data (79
words frequent in the descriptions and absent from every code label), each applied to the
segments, to the phrases, or to both.
| Variant | US any / prec / recall | ISIC | NACE | filer US group@5 / class@10 |
|---|---|---|---|---|
| none (current) | 0.70 / 0.22 / 0.33 | 0.78 / 0.22 / 0.44 | 0.78 / 0.24 / 0.45 | 0.42 / 0.56 |
| NLTK, segments only | 0.65 / 0.20 / 0.29 | 0.76 / 0.20 / 0.41 | 0.78 / 0.22 / 0.41 | 0.42 / 0.57 |
| NLTK, phrases only | 0.70 / 0.21 / 0.32 | 0.78 / 0.22 / 0.44 | 0.81 / 0.25 / 0.47 | 0.42 / 0.56 |
| NLTK, both | 0.65 / 0.19 / 0.28 | 0.76 / 0.20 / 0.41 | 0.76 / 0.22 / 0.41 | 0.42 / 0.57 |
| scikit-learn 318, both | 0.65 / 0.19 / 0.28 | 0.78 / 0.22 / 0.45 | 0.78 / 0.24 / 0.46 | 0.45 / 0.59 |
| learned list, both | 0.70 / 0.23 / 0.35 | 0.76 / 0.22 / 0.45 | 0.76 / 0.23 / 0.44 | 0.41 / 0.58 |
| NLTK + learned, both | 0.70 / 0.22 / 0.33 | 0.76 / 0.22 / 0.46 | 0.78 / 0.23 / 0.45 | 0.44 / 0.60 |
Every difference is within the noise of these sample sizes (about 0.05 on 37 companies and 0.03-0.04 on 200); stripping from the segments alone slightly hurts US on the reviewed sets, and nothing is consistently better. MiniLM was trained on natural sentences and its tokenizer already handles function words, so removing them gains nothing measurable. Decision: no stop-word dependency and no stripping of embedded text. Not tested: the full Stopwords-ISO list (not local; it is the most aggressive and unlikely to help), and using a stop-word list inside the phrase extractor to decide what counts as a content word, which is a separate question from what is embedded.
Phrase extraction: variants tested (2026-10-04, phrase_variants.py). Prompted by junk in the
phrase list on long pages (P&G: “which includes Head Shoulders”, “James Gamble”, “th on the Forbes
Global”, “The Procter Gamble Company”). Extractors tried, each scored like the rest (reviewed sets
and the 200 filer companies, here “is the filer’s group / exact class inside the recommended five”):
| Extractor | phrases per company | US any / prec / recall | ISIC any | NACE any | filer group / class in set |
|---|---|---|---|---|---|
| no phrases | 0 | 0.65 / 0.21 / 0.31 | 0.81 | 0.73 | 0.42 / 0.40 |
| current (E0) | 25 | 0.70 / 0.22 / 0.33 | 0.78 | 0.78 | 0.41 / 0.40 |
| E0 + the two bug fixes (E0fix) | 27 | 0.70 / 0.22 / 0.33 | 0.78 | 0.78 | 0.41 / 0.40 |
E3: drop digits, &, names; NLTK trims |
15 | 0.65 / 0.21 / 0.31 | 0.78 | 0.81 | 0.43 / 0.42 |
| E5: permissive (1-word phrases allowed) | 40 | 0.76 / 0.25 / 0.37 | 0.78 | 0.78 | 0.43 / 0.42 |
& or mostly
capitalised words removed P&G’s division names (“Fabric & Home Care”, “Health Care”), which are
the real categories there; E3 left P&G with two junk phrases.& means “and”; a token containing a digit is a break
point). Scores are identical to before and the broken fragments are gone (“Home Care” and
“Family Care” now appear, “th on the Forbes Global” does not). Mirrored in chunker.py and the
Rust phrases(), with tests; the two agree on all 200 descriptions. The wider extractors
(E3, E5) are left as research.Named-entity removal: tested with a generous proxy, no help (2026-10-04, ner_proxy_eval.py).
The idea: an NER pass could remove people, brands, places and subsidiaries (“James Gamble”,
“Pantene”, “Hitachi Vantara”) that produce junk matches. No NER model is installed, so the upper
bound was simulated by masking every capitalised word that does not start a sentence, which
catches at least as many names as a real NER would (plus false positives such as Title Case
headings). Results (reviewed 37 / filer 200):
| Variant | US any | ISIC any | NACE any | filer group@5 / class@10 |
|---|---|---|---|---|
| none (current) | 0.70 | 0.78 | 0.78 | 0.42 / 0.56 |
| mask names in segments | 0.68 | 0.70 | 0.73 | 0.40 / 0.58 |
| mask names in phrases | 0.70 | 0.78 | 0.81 | 0.42 / 0.56 |
| mask in segments and phrases | 0.68 | 0.70 | 0.76 | 0.40 / 0.58 |
| extract phrases from the masked text | 0.68 | 0.78 | 0.81 | 0.42 / 0.56 |
Masking names in the segments hurts ISIC (0.78 to 0.70); in the phrases it is neutral. Reasons it cannot help much: names carry some industry signal for the embedding (“Vantara”, “Gillette”); the junk phrases that survive are mostly ordinary words that match a code literally, not entities; and an NER tag says a word is a PRODUCT or ORG, not what industry it belongs to. Not pursued: it would add a model download and a new dependency for no measurable gain. If any NLP step is worth trying here it is noun-phrase chunking (picking out what the company makes or does), which is a different tool from NER.
Limits to expect. Marketing prose names businesses in abstract terms (“digital solutions”, “social innovation”) that match nothing well; a description that states what the company makes or sells will always do better. Showing the phrases next to the results lets the user see what was looked for and edit the text.
experiments/ when work
starts); fastembed 7.1.0 model catalogue from the local cargo registry.