Skip to content

DocsReading HEY

Search

How search keeps published projects and launch records visibly apart.

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 ALPHA are 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. 0x alone 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, else 400. 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 is role="listbox" with role="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-expanded is 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 " detail is hidden on the narrowest screens.