The search box on the homepage and on /search finds two kinds of thing, kept
visibly apart: published projects and launch records. This document is
the contract for both and for the autocomplete in front of them.
1. The identity rule
A token's identity is (chain_id, contract_address). A name or a ticker is how
a reader finds a record; it is never what the record is. Consequences:
- A ticker search returns every published project that shares it. Two projects
with the ticker
ALPHAare two results, not one. - Only a full contract address (
0x+ 40 hex) is treated as an identity: it may reach the whole discovered universe, hidden records included, and ranks first. - A partial address (
0x+ 4–39 hex) is a prefix hint within the published catalogue.0xalone matches nothing rather than everything. - Every suggestion and every launch record carries the contract; a suggestion
for a launch record navigates to the search page anchored on that contract
(
/search?q=0x…), never to a page named after a ticker.
2. Published projects: one matcher (matchSearchKeys, 2026-09-27)
Every surface that finds a published project from typed text reads one
bounded key set through one matcher: the public suggest, /search (relevance,
count), every listing's ?q= (projectTextMatch: Explore, Ships, Radar, a
narrative, the map, the Terminal catalogue), the Terminal's suggestions,
/terminal/search, the go resolver and Compare's picker (projectsMatching).
Before, four matchers disagreed on keys, $ handling and order.
The keys — materialized view project_search_keys (migration 0146,
packages/db/src/schema/search.ts), published projects only, about 18,800
keys on production:
| Key | From | Confidence |
|---|---|---|
TOKEN_CONTRACT |
the primary token | 100 |
DECLARED_CONTRACT / FOLLOWUP_CONTRACT |
contract_implementations role declared / followup (serial launchers already excluded, F4/F10); exact-only |
90 / 70 |
NAME, SLUG, TICKER |
the project and its token (a ticker is never an identity: 173 are shared) | 100 |
OLD_SLUG |
project_slug_redirects |
100 |
ALIAS |
derived: the name without " by Virtuals", a token name that differs, a name the project carried before a display-name repair (project_name_history, relationship previous_name, 2026-10-14) |
80 |
DOMAIN |
a WEBSITE source that is verified or an authoritative link, not context-only, available, not disputed — its host and its registrable domain (platform hosts such as vercel.app excluded) |
95 / 85 (registrable 90 / 80) |
REPO, REPO_OWNER |
a GITHUB_REPO / GITHUB_ORG source, verified or authoritative link, same exclusions; the owner of a corroborated repository too |
85 / 80 |
X_HANDLE |
the account of an official X source that is not context-only, available, not disputed or retracted — the account the card prints (2026-10-17, migration 0279); lower case, no @; never X's own paths (search, intent, …) or Robinhood's own accounts |
95 verified / 85 authoritative link / 75 official |
Never keys: hidden launch records, context-only sources, deployer, linked or
holder addresses, any market, price or payment figure. Normal form: NFKC,
lower-case, zero-width characters removed, whitespace collapsed; a ticker loses
$, a host www., a repository .git, an X handle its @.
The question (classifyQuery): a whole contract (0x + 40 hex, any
case), a contract prefix (0x + 4–39 hex), a repository (owner/name or a
GitHub URL), an X account (@handle, x.com/<handle>, twitter.com/<handle>,
a post's x.com/<handle>/status/<id>; 2026-10-17), a URL or host, or words.
Under two characters nothing matches.
The rank — never market cap, price, volume, payment, $HEY holdings or
ship recency:
| Tier | Match |
|---|---|
| 1 | exact contract (token, declared or follow-up) |
| 2 | exact ticker |
| 3 | exact name or slug |
| 4 | exact alias or old slug; exact domain, repository or X handle when the text is shaped like one |
| 5 | prefix of a name, slug, alias, ticker or old slug; then a word inside a name; a token contract prefix |
| 6 | only while tiers 1–5 found fewer than eight projects: a name containing the text (2+ characters, at most five), then typo tolerance (4+ characters, at most three): trigram similarity ≥ 0.5 (4–5 characters) or ≥ 0.46, or word similarity ≥ 0.7 inside a name |
| 7 | a domain or repository equal to or starting with the text (3+ characters); an X handle equal to plain words; for an X-shaped text, a project whose name, slug or ticker is the handle |
| 8 | only when nothing above matched: a name, slug, alias or old slug one edit away — two neighbours swapped, a letter missing, extra or changed (4–40 characters, at most three; never a ticker, contract, domain or handle; 2026-10-17). morhpo → Morpho, unsiwap → Uniswap |
Straight to the project (2026-10-17): on /search, a text that exactly one
published project carries as its whole name, slug or ticker (tiers 2–3, no
second project at either) opens /project/<slug>; never with a fact, launchpad
or sort applied, a help, glossary, narrative or question answer, an issuer
token leading, or another chain's address. A shared name or ticker stays a
results page (exactUniqueAnswer).
Ties: key weight (contract, name, ticker, slug, alias, old slug, domain,
repository, owner), confidence, the shorter key, similarity, the lower-cased
name, the slug. Within tier 6 similarity comes first. Measured on production
(audit B and 2026-09-27): uniswp → Uniswap, Uniswap V2, Uniswap V3;
hoodlok → Hoodlock then hoodLM; hey reserch → Hey Research Lab;
uniswap.org → the three Uniswap pages on app.uniswap.org; $HEY → Hey and
Hey Research Lab (shared ticker, both carry the short contract in the
Terminal), then heyAura.
Visibility at read time. Every match is checked against the project's current visibility when you search, so a project taken down disappears from search at once.
Freshness. The worker refreshes the view CONCURRENTLY every five minutes
(REFRESH_SEARCH_KEYS) and once after each quality-gate sweep, and records the
build in search_key_builds. Between builds a published project whose row
changed after the build is matched on the four keys its own row carries (name,
slug, ticker, token contract) — the overlay in searchKeyMatchCte.
Public /search. The matcher's order is the relevance order; the ids go to
the card query as one array (= any(...)), so a pasted address is primary-key
lookups. A whole address also reaches a discovered record whose token it is
(shown as a launch record). The narrative's name still matches on this page,
after every key match. countSearchProjects uses the same set — it had missed
the A8-01 citext path (1.7–2.2 s, 17.7 s live).
Cost (local copy of production's 18,833 keys, machine under heavy load): plan + execute p50 9 ms, p95 25 ms per question; a full refresh 0.5–0.7 s; view and indexes about 7–11 MB.
Refresh on production (2026-09-30, migration 0200). The refresh averaged
1.1 s (min 0.74 s, 939 calls in five days) against the 0.24–0.26 s measured at
launch. The view's own query was 0.7–1.0 s of it: each website source's host
was parsed five or six times (the hosts CTE, referenced once, was inlined into
every use in domains), the primary token was joined three times, and every
name was normalised twice — on a server whose Postgres runs these regular
expressions at 11–130 µs a call (the same regexp_replace is 13× slower there
than on a laptop). 0200 parses each host once (hosts is MATERIALIZED), joins
the token once (tok) and normalises each name once (pub.name_norm). On
production data the old and new definitions return the same rows (EXCEPT ALL
both ways: 0), and the query took 0.48–0.57 s against 0.69–0.99 s, alternating
runs. Part of what remains was source_updated_at: it moved whenever a project,
token or source row was touched, so a five-minute refresh rewrote about 40 times
more keys than actually changed (210 rows against 5 in four minutes). Nothing
read the column.
No change stamp (2026-09-30, migration 0203). The view no longer carries
source_updated_at (nor the timestamps that only fed it); every other column
and every key is unchanged, so a CONCURRENTLY refresh now rewrites only a key
whose text, display, chain, relationship, confidence, identity or evidence
changed. Measured on production with read-only EXPLAIN ANALYZE, four minutes
after a build: against the stored view, the old definition differed in 599
rows (599 deletes and 599 inserts, every one a timestamp) and the new one in
0. The build query itself is the same cost either way (old 448–476 ms, new
462–508 ms, three alternating runs each; the diff join a refresh runs, 472 ms
against 480 ms), so what goes is the rewrite: on a local view of the same size
and indexes, 600 rewritten rows added 15–190 ms to a refresh, most of it the
trigram GIN index, plus their WAL and dead rows (3,389 dead in the view at the
time). The recorded build was 873 ms (search_key_builds.duration_ms).
3. Launch records (searchLaunchRecords)
Hidden candidates — catalog_status = 'HIDDEN_CANDIDATE' within
ANY_DISCOVERED_PROJECT (approved, is_demo = false in real mode) — that carry
a primary token with a name or a ticker. Matched by project name or token ticker
(contains), ranked exact ticker → name prefix → ticker prefix → the rest, newest
first, at most 20 on the search page.
What a record shows, and where it comes from:
| Fact | Source | When absent |
|---|---|---|
| Name | projects.name |
never |
| Ticker | tokens.symbol |
omitted |
| Contract | tokens.chain_id + tokens.contract_address, with the copy / explorer control the cards use |
never |
| Launched via | projects.launchpad_version, else the candidate's launchpad for the same (chain, contract) (launchedViaFrom) |
"Unknown" |
| Market cap | the cards' tokenMarketReading (freshest stored reading, ≤ 7 days, with its source) |
omitted — never a launchpad figure, never $0 |
| Label | Launch record — not researched by HEY |
never |
Nothing else: no project link, no activity status, no description, no score. HEY has indexed the launch and not researched it, and the row says exactly that.
An exact contract address is answered by searchProjects (as a hidden card,
catalogStatus = 'HIDDEN_CANDIDATE'), so searchLaunchRecords returns nothing
for one and searchCatalogue drops any launch record whose token is already in
the published list. One token, one row.
Cost
The name/ticker OR spans two tables, so PostgreSQL hash-joins hidden projects to
their tokens rather than using projects_name_trgm_idx / tokens_symbol_trgm_idx
(migration 0002). EXPLAIN ANALYZE on 100k synthetic hidden records: ~50 ms,
flat for any query. A UNION of two trigram scans was measured and is faster only
for selective queries (25 ms) while a three-character query took 4× longer, so
the plain shape stays and no migration was added.
4. Suggestions (GET /api/search/suggest?q=)
q: Zod-validated after control characters are stripped (NUL-safe); 2–64 characters, else400. Rate-limited per client address (api.search.suggest, 240/min).Cache-Control: public, max-age=30.- Answer:
{ q, suggestions }, at most 8 items, published projects first (in the matcher's order, identity columns only since 2026-09-27), then launch records. Each item is typed:
{ type: 'project', name, symbol?, contract?, target: '/project/<slug>' }
{ type: 'launch', name, symbol?, contract, launchedVia, target: '/search?q=0x…' }
A hidden card returned by an exact-contract search is typed launch. The same
token never appears twice. Reads HEY's database only; no provider is contacted.
Two additive flags: moreLaunchRecords (2026-10-01) is true when HEY may hold
more launch records than the rows list, and moreProjects (2026-10-16) is true
when more published projects match q than the rows list. With moreProjects
the box ends its list with "All results for “q” →", which runs the full search;
/search itself, when it shows fewer than its total, links the rest on Explore.
5. The box (SearchField)
Progressive enhancement. Server-rendered, it is the plain GET form to /search
it always was — no JavaScript needed. Since 2026-09-05 the same field is also
each listing's own box (action = the listing, variant="compact", hidden
inputs for the listing's other parameters), so q narrows Explore, Ships, Radar
and a narrative page in place, beside the search page's own sort and has. Once mounted it becomes an ARIA
combobox:
- after 2 characters and a 150 ms pause it asks the suggest route (in-flight requests are aborted when the reader keeps typing);
role="combobox",aria-autocomplete="list",aria-expanded,aria-controls,aria-activedescendant; the list isrole="listbox"withrole="option"items; a polite live region announces the count;- Up / Down move (wrapping through "nothing chosen"), Enter on a suggestion goes to its target, Enter with nothing chosen submits the form, Escape closes, Tab and blur close;
- a failed lookup only means no list — the form still submits;
- a lookup still out after 250 ms shows "Searching…", and an answer with nothing
in it shows "No match — press Enter to search", which runs the full search
when chosen (2026-09-27, the Terminal field's words and timing). Both sit
under the listbox, never in it;
aria-expandedis true only when there are options, and typing past an old answer drops it rather than flashing it.
The homepage box (positioning pass, 2026-09-30) is the page's one primary
action, with a visible label, "Search project or paste contract", which is also
its accessible name (labelClassName); the placeholder, "Name, ticker or
contract" (short enough not to clip at 320px), is shared with /search. A whole address typed or pasted there goes,
on Enter, to its permalink /scan/<address> — the go resolver for an address:
a published project redirects to its page, an unreviewed record says so, an
address HEY has never read shows the scan form prefilled. The permalink reads
one indexed row and never scans. A suggestion the reader chose still wins.
At 375 px the list spans the field and truncates; the "via