<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Samuel Chan</title>
    <description>The latest articles on DEV Community by Samuel Chan (@onlyphantom).</description>
    <link>https://dev.to/onlyphantom</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F189820%2F60729a40-2c6e-4d65-8cc9-18421fc9626e.jpg</url>
      <title>DEV Community: Samuel Chan</title>
      <link>https://dev.to/onlyphantom</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/onlyphantom"/>
    <language>en</language>
    <item>
      <title>Building of a Financial Search Engine</title>
      <dc:creator>Samuel Chan</dc:creator>
      <pubDate>Wed, 19 Aug 2026 03:45:11 +0000</pubDate>
      <link>https://dev.to/onlyphantom/building-of-a-financial-search-engine-47hf</link>
      <guid>https://dev.to/onlyphantom/building-of-a-financial-search-engine-47hf</guid>
      <description>&lt;p&gt;The following article is a technical piece I wrote for &lt;a href="https://sectors.app/" rel="noopener noreferrer"&gt;Sectors&lt;/a&gt;, a dev-friendly Financial Data Platform built for the Singapore and Indonesia financial markets (original version with more interactive examples &lt;a href="https://sectors.app/bulletin/search-architecture" rel="noopener noreferrer"&gt;here&lt;/a&gt;). You can read more about what I do on &lt;a href="https://supertype.ai/p/samuel" rel="noopener noreferrer"&gt;my profile: Samuel Chan&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Technical Pieces of a Financial Search Engine
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdjc6gcuej4cfz2i4r7z4.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdjc6gcuej4cfz2i4r7z4.webp" alt="The Sectors Search Console, a full-screen command palette with results laid out across parallel columns for tickers, ownership, key people, and news" width="752" height="572"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Before any of the engineering, it helps to be honest about what people are actually trying to do when they search a financial platform, because it is not one thing. Someone might know the three- or four-letter ticker and want to jump straight to it (e.g. "O39" for OCBC Bank, "BBCA" for Bank Central Asia).&lt;/p&gt;

&lt;p&gt;More often they remember a fragment of a name, or the sector a company sits in, or the family that controls it, or a half-formed phrase like that "coal company in Kalimantan" or a "aerospace company that makes satellites." They might be looking for a company, a broker, a shareholder, a key person or a conglomerate -- any of which could be the right answer to a Search Console query that is only half-remembered, misspelled, or ambiguous.&lt;/p&gt;

&lt;p&gt;Our Search Console must be able to absorb all of that imprecision and still land the user on the right page, and it must do so across more than one kind of entity. A company is searchable, but so is a sector, an index, a broker, a major shareholder, and a corporate group that ties dozens of listed companies together. In the search space, these are all first-class citizens and a multi-modal query should return any combination of them that is relevant.&lt;/p&gt;

&lt;p&gt;So where does that leave the architectural requirements? We need &lt;strong&gt;breadth, precision under ambiguity, and trust&lt;/strong&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Breadth means the corpus must be wide enough to cover every entity a user might be looking for, and it must be able to tolerate half-remembered input.&lt;/li&gt;
&lt;li&gt;Precision means the ranking must be able to cut through near-duplicates and understand which of the matches is most likely what the user intended.&lt;/li&gt;
&lt;li&gt;Trust means the system must be deterministic and fast enough to keep up with typing, so that a user can feel confident in the results they see.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That &lt;strong&gt;breadth&lt;/strong&gt; is the first prerequisite, and it is mostly a data problem rather than an algorithmic one. Each entity carries several identities at once. A single company on the Indonesia Stock Exchange has a ticker, a formal legal name wrapped in boilerplate like "PT" and "Tbk," a common name people actually say out loud, a sector classification, a paragraph of business description, and a web of ownership relationships. Any one of those is a legitimate way in. The job of the corpus is to gather all of them, normalise away the noise that gets in the way of matching (the honorifics in a person's name, the legal suffixes on a company), and resolve identities that appear in more than one dataset so that a shareholder named one way in an ownership filing lines up with the same person named slightly differently elsewhere.&lt;/p&gt;

&lt;p&gt;The second prerequisite is &lt;strong&gt;precision under ambiguity&lt;/strong&gt;. Financial corpora are full of near-duplicates. Dozens of names begin with "Bank," a great many descriptions mention banking, and a substring as innocent as "pan" hides inside plenty of unrelated words. A search that merely finds everything matching the query is close to useless here, because everything is a lot. What separates a good financial search from a noisy one is the ranking: the ability to understand that a query is probably aimed at a name rather than a description, that a rarer word in the query carries more intent than a common one, and that a result reached only through an indirect relationship deserves to sit below a direct hit. Precision is not a finishing touch in this domain. It is the product.&lt;/p&gt;

&lt;p&gt;The third prerequisite is &lt;strong&gt;trust&lt;/strong&gt;, and trust in this context mostly means determinism and speed. People making decisions with money do not want a search box that occasionally invents an answer or pauses to think. For the common navigational query, they want the same correct result every time, returned faster than they can perceive. In the case of our AI Search, we want each query to return exactly the same answer every time, along with an explanation of the query path it takes to arrive there. Users of AI Search must be able to trust that the answer they get is the answer they would have gotten if they asked again, and that it is grounded in the data rather than hallucinated.&lt;/p&gt;

&lt;p&gt;That speed requirement pushes us toward a structural, in-memory approach for the bulk of traffic, with heavier natural-language and AI-assisted retrieval reserved for the genuinely open-ended questions.&lt;/p&gt;



&lt;blockquote&gt;
&lt;p&gt;Gather every identity an entity has and normalise it, retrieve, rank while&lt;br&gt;
  cutting out near-duplicates, and deliver all of it at the speed of typing.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;With those requirements in mind, we started designing a search architecture that could meet them, and this article is a tour of the pieces we ended up with, in the order that a query travels: how the Sectors engineering team builds and holds the corpus, how we retrieve against it, how we rank what comes back, how we sharpen that ranking with term weighting, and how we keep the whole pipeline off the thread the user is typing into.&lt;/p&gt;

&lt;h2&gt;
  
  
  An In-Browser Search Architecture
&lt;/h2&gt;

&lt;p&gt;Even before the &lt;a href="https://sectors.app/release/3.6.0" rel="noopener noreferrer"&gt;Search Console&lt;/a&gt; update, our Search was a critical part of the Sectors experience and ranked as a top feature in anonymized usage analytics. People reach for it the way they reach for the address bar in a browser, and that sets a high bar: the first keystroke should already be doing useful work, and the tenth should feel no slower than the first.&lt;/p&gt;

&lt;p&gt;The decision that shapes everything else is that our primary search runs entirely in the browser. There is no round trip to a server for the common case of finding a ticker, a sector, a conglomerate, or a shareholder. The corpus that powers &lt;a href="https://sectors.app/search" rel="noopener noreferrer"&gt;the search console experience&lt;/a&gt; is built and refresed periodically (typically twice a week) offline, and when ready, it is shipped to the client (your browser) and gets indexed there in-memory. This decision means that the slowest part of a query is no longer the network. It is whatever work we choose to do between the user pressing a key and the results painting.&lt;/p&gt;

&lt;p&gt;&lt;br&gt;
  We still lean on server-side and AI-assisted search for natural-language&lt;br&gt;
  queries, and we have written about that approach in &lt;a href="https://sectors.app/bulletin/ai-search" rel="noopener noreferrer"&gt;Building Search Engines&lt;br&gt;
  in the age of AI&lt;/a&gt;. This article is about the layer&lt;br&gt;
  underneath it: the deterministic, instant, structural search that handles the&lt;br&gt;
  overwhelming majority of queries without ever leaving the tab.&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;There are four moving parts worth describing on their own terms. &lt;strong&gt;We build an in-memory index over several datasets, we query that index with substring semantics across multiple scopes at once, we re-rank the raw matches into something that respects user intent, and we do all of the indexing and searching off the main thread so the interface never stutters&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The sections below tackle each of those pieces one at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Indexing
&lt;/h2&gt;

&lt;p&gt;The retrieval primitive we build on is a compact in-memory index. It exposes a deliberately small surface: you add a document with &lt;code&gt;index(uid, text)&lt;/code&gt;, and you retrieve matching identifiers with &lt;code&gt;search(query)&lt;/code&gt;. The interesting design choice is not the primitive itself but how we feed it.&lt;/p&gt;

&lt;p&gt;Every company has several fields a financial analyst or market research might search by: its ticker, its legal or display name, and a simple line of business description. Rather than maintain three separate indices, we register all three fields under the same identifier, the ticker. The index merges them into one searchable document keyed by that ticker, so a query that hits any field resolves back to the same company.&lt;/p&gt;

&lt;p&gt;This collapsing of fields under one identifier is convenient for retrieval and, as we will see later, mildly inconvenient for ranking. It is a trade we make on purpose. &lt;strong&gt;Retrieval is the hot path and should stay simple&lt;/strong&gt;; ranking, on the other hand, is where we are afforded more complexity to solve the precision problem.&lt;/p&gt;

&lt;p&gt;The second indexing decision is about when each dataset becomes available, and here we are explicitly progressive rather than eager. The Indonesian stock index is built synchronously when the dialog mounts, because it is the dataset most queries touch and we want it ready before the user has finished typing the first word. The conglomerate dataset is heavier, so we import it lazily and build its index in the background once the more urgent work is done.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// IDX is ready immediately. Everything else streams in behind it.&lt;/span&gt;
&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;buildIdxIndex&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;groups&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;loadConglomerates&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;buildGroupIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;})();&lt;/span&gt;
  &lt;span class="c1"&gt;// SGX (Singapore) stocks waits on a small fetch&lt;/span&gt;
  &lt;span class="c1"&gt;// to determine which tickers are valid, then builds in the background.&lt;/span&gt;
  &lt;span class="nf"&gt;loadValidSgxTickers&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;buildSgxIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Singapore dataset index waits on a small fetch, filters the static description file against the set of valid tickers, and only then becomes searchable. The user sees Indonesian results the instant they type, Singaporean results a few milliseconds later, and conglomerate results once the larger payload has settled. Nobody waits on the slowest dataset to see the fastest one.&lt;/p&gt;

&lt;p&gt;The reason this staggering is safe is the same reason the whole thing is fast: each index is independent, and the query layer treats a not-yet-built index as simply contributing nothing yet. There is no global "ready" gate that the entire feature blocks on. Readiness is per-index, and the experience degrades gracefully from the moment the dialog opens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Searching
&lt;/h2&gt;

&lt;p&gt;A query in our search bar is not always a plain string. It is closer to a command palette (like the one in VS Code, or Claude Code), where a leading slash selects a scope before the rest of the input is treated as the search argument. Typing &lt;code&gt;/sg banks&lt;/code&gt; narrows the search to Singapore; &lt;code&gt;/id&lt;/code&gt; narrows it to Indonesia. The grammar is small and deterministic, parsed before anything touches the index, so scoping never costs a model call or a network hop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawQuery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;scope&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scope&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once we know the &lt;code&gt;scope&lt;/code&gt;, we query every relevant index concurrently. There is no reason to search equities, sectors, and conglomerates in sequence when each is an independent in-memory lookup, so we fire them all off at once and wait for them to come back together. The search layer does not care which indices are in play, so it can treat a missing one as simply contributing no results rather than an error state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;idxUids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sgxUids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sectorUids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;groupUids&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="nx"&gt;searchIdx&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;idxIndex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawQuery&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;([]),&lt;/span&gt;
  &lt;span class="nx"&gt;searchSgx&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;sgxIndex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawQuery&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;([]),&lt;/span&gt;
  &lt;span class="nx"&gt;sectorIndex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawQuery&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nx"&gt;groupIndex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawQuery&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="c1"&gt;// any future indices go here without&lt;/span&gt;
  &lt;span class="c1"&gt;// changing the shape of the code that follows&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The matching itself is substring-based across the merged document. A multi-word query is conjunctive: searching for &lt;code&gt;bank pan&lt;/code&gt; returns companies whose indexed text contains both &lt;code&gt;bank&lt;/code&gt; and &lt;code&gt;pan&lt;/code&gt; somewhere, in any field. That is a powerful default for discovery, because it lets a half-remembered name or a sector keyword surface the right company.&lt;/p&gt;

&lt;p&gt;It is also, by its nature, &lt;em&gt;generous&lt;/em&gt;. A two-word query against a corpus this dense can return thirty or forty candidates ("noisy result sets"), and substring matching will happily count &lt;code&gt;pan&lt;/code&gt; inside &lt;code&gt;expand&lt;/code&gt; or &lt;code&gt;Japan&lt;/code&gt;. Retrieval gives us a set of things that match. It says nothing about which of them the user actually meant (i.e. the "intent" behind the query).&lt;/p&gt;

&lt;p&gt;That distinction, between membership and intent, requires Sectors engineering to build second layer of logic on top of the raw retrieval, a relevance model that can understand which of the matches is most likely what the user intended and rank it accordingly. The next two sections are about how we do that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Relevance Ranking
&lt;/h2&gt;

&lt;p&gt;The raw output of retrieval is a list of identifiers in index order. There is no score attached, and because we deliberately collapsed name, ticker, and description under a single identifier, the index cannot even tell us which field produced the match. A company that literally is "Bank Pan Indonesia" comes back indistinguishable from one whose description happens to contain both words in unrelated sentences.&lt;/p&gt;

&lt;p&gt;In fact, when the &lt;a href="https://sectors.app/release/3.6.0" rel="noopener noreferrer"&gt;Search Console&lt;/a&gt; first shipped, that was exactly the state of affairs. Sectors had a powerful index and an extremely fast retrieval, but the result sets were not ranked at all, with "Bank Pan Indonesia" sitting somewhere in the middle of the list rather than at the top. This was surfaced by @jigsawinthecity immediately, and confirm a problem we had anticipated but remained unsolved.&lt;/p&gt;

&lt;p&gt;So we got to work and reconstruct the signals, building upon the earlier architectural foundations for our financial search engine.&lt;/p&gt;

&lt;p&gt;After the retrieval step, we examine each candidate against the raw fields we already hold in memory and derive a small set of ranking signals. The priority order we want is intuitive and worth stating in plain language before any code: a match in the name beats a match in the description, and both beat a match reached only through an indirect connection such as a shareholding link. Within that, an exact phrase in the name is the strongest signal of all.&lt;/p&gt;

&lt;p&gt;We capture those signals as a key, computed per candidate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;SearchRankKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;inFull&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;inName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;inDesc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;lq45&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The temptation here is to flatten these into a single weighted score, something like &lt;code&gt;1000 * inFull + 100 * inName + 10 * inDesc&lt;/code&gt;. This is the approach taken by many search engines, and it can work well when the signals are continuous and the weights are carefully tuned. While seemingly robust, it actually can become brittle and opaque as scoring constants proliferate and evolve, sometimes in non-obvious ways. Programmers call them "magic numbers" for a reason -- they exist to make the math work, not because they have inherent meaning, and that makes them hard to justify and easy to break.&lt;/p&gt;

&lt;p&gt;What we actually want is a stricter, more predictable ordering; one that uses a tiered approach to relevance, with a comparator consulting each signal in turn and only moving to the next one when there is a tie. A match in the name should always beat a match in the description, no matter how many description matches there are, and an exact phrase match should always beat a partial token match, no matter how many of those there are.&lt;/p&gt;

&lt;p&gt;The comparator is simple and transparent, with no constants to tune and no risk of one signal quietly drowning out another.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Each `||` is a tier. A later signal only matters when the earlier ones tie.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;compareSearchRank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SearchRankKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SearchRankKey&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inFull&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inFull&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inName&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inName&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inDesc&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inDesc&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lq45&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lq45&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This reads top to bottom as exactly the priority we described, with no constants to justify and no risk that twenty weak description hits quietly outweigh one strong name hit. Sorting is stable, so candidates that genuinely tie keep their retrieval order, which is predictable and deterministic (recalling our &lt;strong&gt;trust&lt;/strong&gt; requirement from earlier). The comparator is also easy to test in isolation, which is a nice bonus.&lt;/p&gt;

&lt;p&gt;The ranking layer also gives us a natural place to solve the "noisy result sets" problem. Once a query has produced any name-tier matches, the description-only and connection-only tail is almost always noise, e.g. the &lt;code&gt;pan&lt;/code&gt;-inside-&lt;code&gt;expand&lt;/code&gt; or &lt;code&gt;pan&lt;/code&gt;-inside-&lt;code&gt;Japan&lt;/code&gt; accidents.&lt;/p&gt;

&lt;p&gt;So we apply a cutoff: if strong matches exist, we drop the weak tail; if nothing matched a name at all, we keep everything, because a narrow query should never be starved down to an empty list.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;applyRelevanceCutoff&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SearchRankKey&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;strong&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inFull&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inName&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;strong&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;strong&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cutoff is deterministic and explainable, which matters. When a user asks why a particular result did or did not show up, "it had no match in the name and there were better matches available" is an answer the Sectors Engineering team can stand behind. An opaque score threshold is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lite-IDF Weighting for Intent and Discrimination
&lt;/h2&gt;

&lt;p&gt;The comparator gets the tiers right, but it exposed a subtler problem the moment we used it on real queries. Consider &lt;code&gt;bank pan&lt;/code&gt; again. "Panin Financial" matches on the name (albeit having ranked lower in the result sets), through the token &lt;code&gt;pan&lt;/code&gt;. But then does "Bank Jago", through the token &lt;code&gt;bank&lt;/code&gt; -- even when the user intent provides no evidence to support "Bank Jago" over "Panin Financial."&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;inName&lt;/code&gt; is a simple count of matched tokens, both score one, they tie, and the tiebreaker decides the order more or less arbitrarily. The blue-chip banks float up and "Panin Financial", which is far closer to the intent of what someone typing "pan" is looking for, ends up buried below them.&lt;/p&gt;

&lt;p&gt;The issue is that not all tokens carry the same amount of information. In a corpus of Indonesian listed companies, &lt;code&gt;bank&lt;/code&gt; appears in dozens of names and discriminates almost nothing. &lt;code&gt;pan&lt;/code&gt; appears in a handful and is highly discriminating. Treating a &lt;code&gt;bank&lt;/code&gt; match and a &lt;code&gt;pan&lt;/code&gt; match as equally valuable is the bug.&lt;/p&gt;

&lt;p&gt;This is the exact problem that inverse document frequency was invented for, so Sectors Engineering borrowed this idea from information retrieval &lt;sup&gt;1&lt;/sup&gt; and implement in pure typescript code our own version of it, a version we call "lite-idf".&lt;/p&gt;

&lt;p&gt;Here's the basic premise (not our own original idea, but worth stating plainly) of TF-IDF: a token that appears in many documents is less informative than one that appears in few, so we should weight the latter more heavily when it matches. In our case, the "documents" are the candidate names we already have in hand, and the "tokens" are the words in the query.&lt;/p&gt;

&lt;p&gt;So here's the game plan we cooked up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;We take the query and split it into tokens, e.g. &lt;code&gt;bank pan&lt;/code&gt; becomes &lt;code&gt;["bank", "pan"]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;We take the candidate names and compute how many of them contain each token, e.g. &lt;code&gt;["Bank Jago", "Panin Financial", "Bank Central Asia"]&lt;/code&gt; contains &lt;code&gt;bank&lt;/code&gt; in two names and &lt;code&gt;pan&lt;/code&gt; in one.&lt;/li&gt;
&lt;li&gt;Then, we compute the weight for each token based on its rarity, so rarer tokens contribute more to the score.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Rather than counting matched tokens, we weight each token by how rare it is, so matching a rare token contributes more to the score than matching a common one. We compute the document frequency over the candidate names we already have in hand, so there is no separate statistics table to build or keep in sync.&lt;/p&gt;

&lt;p&gt;Here is how the math works out in code. The &lt;code&gt;tokenNameWeights&lt;/code&gt; function takes the query tokens and the candidate names, counts how many names contain each token, and computes a weight for each token based on its rarity.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;tokenNameWeights&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="nx"&gt;names&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)[],&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lowered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;names&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lowered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;weights&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;lowered&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;df&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// Rarer token -&amp;gt; larger weight. The +1 keeps it finite when df is 0.&lt;/span&gt;
    &lt;span class="nx"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;df&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two small details in the formula above: The &lt;code&gt;1 + df&lt;/code&gt; in the denominator handles the case where a token matches no name at all, which would otherwise result in a &lt;code&gt;divide by zero&lt;/code&gt; error. The &lt;code&gt;1 +&lt;/code&gt; in the logarithm keeps the weight finite when a token matches every name, which would otherwise result in a &lt;code&gt;log(0)&lt;/code&gt; error.&lt;/p&gt;

&lt;p&gt;Finally, the logarithm compresses the dynamic range, so a token that is fifty times rarer does not get fifty times the weight and steamroll everything else. With those weights, &lt;code&gt;pan&lt;/code&gt; ends up worth roughly twice what &lt;code&gt;bank&lt;/code&gt; is worth, which is enough to lift "Panin Financial" above the generic banks without disturbing the tier structure around it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fybq8iov5zwob41zqis94.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fybq8iov5zwob41zqis94.webp" alt="Search results for the query " width="700" height="557"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The part I am happiest with is how it plugs in. The ranking function does not know that weighting exists (it doesn't have to). It accepts a &lt;code&gt;weightOf&lt;/code&gt; callback that defaults to returning one, which means the ranker on its own is still the plain count-based version, fully testable in isolation. Weighting is injected from the outside, and the two concerns, the structure of the ranking and the importance of individual terms, stay orthogonal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;rankSearchEntry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;description&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;isLQ45&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="nx"&gt;weightOf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;SearchRankKey&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;inName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ... accumulate weightOf(token) per matched token instead of a raw count&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That additivity also guarantees an invariant we care about, for free.&lt;/p&gt;

&lt;p&gt;A name that matches both tokens scores the sum of two positive weights and therefore always beats a name that matches only one. Weighting can reorder candidates within a tier, but it can never violate the larger rule that more name coverage is better. We get the nuance of term importance without giving up the guarantees of the tiered model.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;OK, but surely this has limitations?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Yes, but a small one. The document frequency is computed over the result set rather than the whole corpus, which is cheap and self-calibrating but means a single-token query has nothing to discriminate on, every candidate matched the one token, so the weights collapse to equal. We consider this harmless, because rarity only needs to matter when there is more than one term in play. And because the underlying match is still substring-based, the frequency counts inherit that bluntness. These are deliberate simplifications, not oversights&lt;sup&gt;2&lt;/sup&gt;, and the implementation that Sectors Engineering ended up strikes a good balance between the ideal and the practical, with a meaningful boost for rarer tokens without the cost of building and maintaining a full statistics table over the corpus.&lt;/p&gt;

&lt;p&gt;If you haven't got to try &lt;a href="https://sectors.app/search" rel="noopener noreferrer"&gt;Sectors Search Console&lt;/a&gt; yet -- do it before you continue reading so you can see the effect of this weighting in action by typing &lt;code&gt;bank pan&lt;/code&gt; into the search bar. The Panin entities should float to the top above the generically-named banks, with &lt;code&gt;pan&lt;/code&gt; being worth about twice as much as &lt;code&gt;bank&lt;/code&gt; in the name matches.&lt;/p&gt;

&lt;p&gt;The whole addition we make since the &lt;a href="https://sectors.app/release/3.6.0" rel="noopener noreferrer"&gt;Search Console&lt;/a&gt; update is this weighting layer, and it is a great example of how we can solve a problem that emerges from the real world with a small piece of code that plugs into the existing architecture, keeping the whole thing to a handful of lines that run in microseconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debouncing and Highlighting the Query
&lt;/h2&gt;

&lt;p&gt;Two small pieces sit on either end of the pipeline we have described, and both exist for the same reason: a person types in bursts, not in deliberate single keystrokes, and the experience should feel like it is keeping pace with the typing rather than reacting to every individual character.&lt;/p&gt;

&lt;p&gt;At the front of the pipeline is debouncing&lt;sup&gt;3&lt;/sup&gt;. The textarea updates &lt;code&gt;query&lt;/code&gt; on every keystroke, because the input box must always feel immediate, but the value that actually drives indexing, retrieval, ranking, and re-sorting is a &lt;code&gt;debouncedQuery&lt;/code&gt; that trails it by a short window. A burst of five characters typed in a tenth of a second collapses into a single search instead of five, and the expensive part of the pipeline only runs once the user has paused.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setDebouncedQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Is this truly robust? Almost. Adding debouncing is the kind of thing that is sometimes not worth the risk as it can introduce edge cases like stale queries or flickering results if not handled carefully, for seemingly innocent reasons. Consider the moment a user types the last character that crosses the minimum-length threshold for search. The &lt;code&gt;debouncedQuery&lt;/code&gt; is still trailing behind, so for a moment it holds a value that no longer reflects what the user has on screen. If we fed that stale-but-too-short value into the search, the result list would flash the default state (e.g. "type at least three characters") before updating to the real results.&lt;/p&gt;

&lt;p&gt;In our case though, that is fully mitigated by the fact that we fall back to the live &lt;code&gt;query&lt;/code&gt; when &lt;code&gt;debouncedQuery&lt;/code&gt; is below the threshold, so the search never runs against a stale-but-too-short string and the result list never flashes the default state on the way to a real answer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;searchQuery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MIN_SEARCH_LENGTH&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;debouncedQuery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;MIN_SEARCH_LENGTH&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;debouncedQuery&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The in-dialog debounce above is hand-rolled because it is tangled up with that threshold logic, but the same idea is needed all over the Sectors application wherever a keystroke triggers asynchronous work, so we also keep it as a tiny reusable hook, &lt;code&gt;useDebouncedValue&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It returns a value that only updates once its input has stopped changing for the given window of time, so it can be used to debounce any value, not just the search query. In essence, a debounced value coalesces a burst of input changes into a single fetch rather than firing one request per character. The real-time news column in Sectors Search Console does exactly this, debouncing the target by 200ms before the fetch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Search Highlighting
&lt;/h3&gt;

&lt;p&gt;At the other end of the pipeline, once results are painted, is highlighting. Having gone to the trouble of understanding which tokens in the query carried intent, it would be a shame not to show the user where those tokens actually landed in each result. The &lt;code&gt;useTextHighlight&lt;/code&gt; hook takes the same query tokens we parsed for ranking and wraps every occurrence of them in the rendered names, descriptions, and shareholder labels in a &lt;code&gt;&amp;lt;mark&amp;gt;&lt;/code&gt;, so the match is visible at a glance rather than something the user has to hunt for.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;useHightlight&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;searchWords&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;aiResult&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;aiResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="na"&gt;highlightClassName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;highlight&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hook finds the spans and returns a &lt;code&gt;highlight(text)&lt;/code&gt; function that the result rows call directly, e.g. &lt;code&gt;{highlight(item.name)}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A few light engineering touches: we apply deduplication and trim the search words so an empty or repeated token does nothing, and it is fed from the same &lt;code&gt;debouncedQuery&lt;/code&gt; as the search itself, so the highlights never lag behind or run ahead of the result set they are annotating.&lt;/p&gt;

&lt;p&gt;The visual reinforcement therefore closes a loop that the ranking opened, so the user can see not just that "pan" is more important than "bank" but also where "pan" is actually landing in the results, which is a nice bit of feedback to have when they are trying to figure out how to phrase their query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Driving the Results from the Keyboard
&lt;/h2&gt;

&lt;p&gt;We opened this article by comparing the search box to the address bar in a browser, and there is a corollary to that comparison that is easy to underrate: the address bar is something you never have to touch the mouse to use. You type, you arrow down to the suggestion you want, you press enter, and you are gone.&lt;/p&gt;

&lt;p&gt;We want to ship a financial search engine that produces an identical console-like experience, where the more savvy powerusers can fly through the results by driving the search entirely from the keyboard, without having to lift their hands to reach for the trackpad and click on a result.&lt;/p&gt;

&lt;p&gt;Sectors Search Console is therefore fully keyboard-drivable, and the machinery that makes it so is another part the Sectors Engineering team is deliberate about.&lt;/p&gt;

&lt;p&gt;The first thing to notice is that the results are not a single list. They are laid out as parallel columns: equities and sectors on the left, then a stacked middle of ownership records, key people, and news. Each column carries its own cursor, so the navigation problem is genuinely two-dimensional rather than the one-dimensional up/down that most search boxes ship. We track that as a tuple of row positions, one per column, alongside the column that is currently active.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// activeCol: 0=tickers, 1=ownership, 2=people, 3=news&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;activeCol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setActiveCol&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;rowIdx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setRowIdx&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A column whose cursor is &lt;code&gt;-1&lt;/code&gt; is idle, with no highlighted row, which is exactly the state we want when the dialog opens or when the user has just edited the query and the old result set no longer exists. It means the visible highlight is always a faithful reflection of the internal state, because "nowhere yet" is a state we can represent rather than having to fake with row zero.&lt;/p&gt;

&lt;p&gt;The down and up arrows move within a column, but they also know how to step between the stacked sections. When the cursor reaches the bottom of the ownership list, the next press of ↓ does not stop dead; it falls through to the next non-empty section below it, and ↑ does the symmetric thing. The left and right arrows treat the layout the way the user sees it, as two columns rather than four: a single hop between the left equities column and whichever of the stacked sections actually has rows in it.&lt;/p&gt;

&lt;p&gt;The part that takes the most care, though, is not the grid itself but everything that has to be allowed to override it. The same &lt;code&gt;handleKeyDown&lt;/code&gt; is the entry point whether the user is typing a scope command, sitting on the empty splash screen, looking at a "did you mean" suggestion, or navigating real results, and each of those is effectively a different modal surface fighting for the same keys.&lt;/p&gt;

&lt;p&gt;More concretely, while the cursor is still resting on the search bar with no row selected, the left and right arrows are deliberately &lt;em&gt;not&lt;/em&gt; captured, because at that moment the textarea is a text field first and a navigator second. The user should be able to move the caret and edit a word in the middle of their query; column navigation only takes over once ↓ has actually moved the cursor down into the results.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Cursor still on the search bar: the textarea is a text field first,&lt;/span&gt;
&lt;span class="c1"&gt;//  so let ←/→ move the caret and edit the query mid-string.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ArrowLeft&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ArrowRight&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;activeCol&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;rowIdx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On top of the grid sit a few hotkeys that only springs to live once the user is in keyboard-nav mode, so they can never react to a keystroke meant for the query.&lt;/p&gt;

&lt;p&gt;Pressing &lt;code&gt;b&lt;/code&gt; on a focused company or sector toggles it in the watchlist; &lt;code&gt;Cmd&lt;/code&gt;/&lt;code&gt;Ctrl&lt;/code&gt;+&lt;code&gt;Enter&lt;/code&gt; escalates the current query to the heavier AI and natural-language search rather than the instant structural one. And there are small touches of feedback woven through, like a flash on the search bar when  pops the cursor back out of the results, so the boundary between "navigating" and "typing" is something the user can see and not just infer.&lt;/p&gt;

&lt;p&gt;None of this changes a single result or its ranking. It is entirely about the last few inches between a correct answer being on screen and the user landing on it, and experience is the sum of those inches. The Sectors Engineering team has spent a lot of time on them, and we are proud to present a keyboard-driven financial search engine that is as fast to navigate as it is comprehensive in coverage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Web Worker Support
&lt;/h2&gt;

&lt;p&gt;Everything described so far has a latency budget measured against a single unforgiving constraint: it has to keep up with typing. A person types several characters a second, and every one of them can trigger a fresh index build check, spawn several concurrent searches, triggers one or more re-rank, and one or more re-sort.&lt;/p&gt;

&lt;p&gt;If any of that runs on the main thread, the browser cannot also be laying out the page and responding to the next keystroke, and the result is a stuttery, laggy experience that feels broken -- even when the search results are correct and the search engine is marvelously well-designed.&lt;/p&gt;

&lt;p&gt;So we push all of that work off the main thread.&lt;/p&gt;

&lt;p&gt;The indexing work? It does not happen on the main thread. The searching work? It does not happen on the main thread. The ranking work? It, too, does not happen on the main thread.&lt;/p&gt;

&lt;p&gt;The only thing that happens on the main thread is the user typing and the interface painting, and that is exactly what we want. Everything else is a background worker&lt;sup&gt;4&lt;/sup&gt; sitting on a separate thread, doing the heavy lifting of execution (e.g. to build the index, search it, and rank the results) without ever blocking the main thread. The main thread, the one painting the interface and capturing keystrokes, never does the work beyond posting the financial queries across the thread boundary and getting a list of identifiers back.&lt;/p&gt;

&lt;p&gt;This is why every interaction with the index in our code is asynchronous. A &lt;code&gt;search&lt;/code&gt; call returns a promise that resolves when the worker has done its work and messaged back, and index construction is fire-and-forget from the main thread's point of view. The payoff is robustness under the kind of conditions that would otherwise hurt. The conglomerate index can be building in the worker while the user is already typing and getting Indonesian equity results, and the two never contend for the same thread.&lt;/p&gt;

&lt;p&gt;Pushing work onto another thread does introduce one classic hazard, and it is worth showing how we handle it. Because results arrive asynchronously, a slow response to an earlier query can land after a faster response to a later one and overwrite fresh results with stale ones&lt;sup&gt;5&lt;/sup&gt;. We guard every query with a cancellation flag tied to the lifetime of that specific keystroke's effect.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cancelled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;runSearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawQuery&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cancelled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// a newer query has superseded this one&lt;/span&gt;
  &lt;span class="nf"&gt;setResults&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the query changes, a cleanup runs and sets its flag, so any in-flight response it was waiting on is discarded the moment it returns. The user always sees results for what they have currently typed, never a flicker of an older query's answer. It is a small pattern, but it is the difference between an asynchronous search that feels solid and one that feels haunted.&lt;/p&gt;

&lt;p&gt;The combination is what makes the feature feel instant rather than merely fast. Retrieval is cheap because the corpus is in memory. The interface stays responsive because the corpus is in memory on a thread that is not the interface's. And the results stay correct because we are disciplined about which response is allowed to win.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing Notes
&lt;/h2&gt;

&lt;p&gt;None of the individual pieces here are exotic. An in-memory index, conjunctive substring matching, a tiered comparator, a touch of inverse document frequency, a debounce, a highlighter, keyboard navigation, a worker thread.&lt;/p&gt;

&lt;p&gt;Where Sectors Engineering shows its craft is in how those pieces are composed, with each layer solving a problem the previous one created.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Indexing under a shared identifier makes retrieval simple and forces ranking to reconstruct field information&lt;/li&gt;
&lt;li&gt;Generous substring matching makes discovery powerful and forces a relevance model to tame the volume&lt;/li&gt;
&lt;li&gt;A strict tiered ranking gets the order mostly right and exposes the term-importance gap that the lite-idf weighting closes&lt;/li&gt;
&lt;li&gt;Debouncing keeps that pipeline from running on every intermediate keystroke, and highlighting reflects the matched terms back so the user can see why a result surfaced&lt;/li&gt;
&lt;li&gt;Keyboard navigation turns the ranked columns into something a power user can fly through without the mouse&lt;/li&gt;
&lt;li&gt;And the worker thread is what lets all of that run on every keystroke without the user ever noticing it happening.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We revisit these decisions often, the way we revisit most architecture at Supertype, and some of them will look different a year from now. But the shape of the system, &lt;strong&gt;retrieve broadly, rank strictly, and keep the heavy work off the thread&lt;/strong&gt; the user is touching, has held up well, and it is a shape I would reach for again.&lt;/p&gt;

&lt;p&gt;If you are building information-retrieval systems of your own and want to compare notes, &lt;a href="https://sectors.app/enterprise" rel="noopener noreferrer"&gt;Sectors for Enterprise&lt;/a&gt; is where those conversations start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Footnotes
&lt;/h2&gt;

&lt;p&gt;&lt;br&gt;
&lt;a id="footnote-1"&gt;1&lt;/a&gt;: Inverse document frequency is a classic information-retrieval weighting that scores a term by the inverse of how many documents contain it, so that common terms count for little and rare terms count for more. The canonical reference is the family of &lt;a href="https://en.wikipedia.org/wiki/Tf%E2%80%93idf" rel="noopener noreferrer"&gt;tf-idf&lt;/a&gt; weighting schemes; our use is a deliberately reduced version of the idf half, computed over the candidate set rather than a precomputed corpus statistic.&lt;/p&gt;

&lt;p&gt;&lt;a id="footnote-2"&gt;2&lt;/a&gt;: The full-strength successor to plain tf-idf in information retrieval is &lt;a href="https://en.wikipedia.org/wiki/Okapi_BM25" rel="noopener noreferrer"&gt;Okapi BM25&lt;/a&gt;, which adds term-frequency saturation (so the tenth occurrence of a word counts&lt;br&gt;
for far less than the first) and document-length normalisation. We deliberately stop short of it: our matches are boolean substring hits rather than counted term frequencies, and our "documents" are short names, so most of the machinery BM25 gets us would have little to act on here.&lt;/p&gt;

&lt;p&gt;&lt;a id="footnote-3"&gt;3&lt;/a&gt;: The term &lt;em&gt;debounce&lt;/em&gt; is borrowed from electronics, where the contacts of a mechanical switch physically &lt;a href="https://en.wikipedia.org/wiki/Switch#Contact_bounce" rel="noopener noreferrer"&gt;bounce&lt;/a&gt; for a few milliseconds and the circuit must wait for them to settle before registering a single press. The software analogue waits for input to "settle" for a fixed window before acting on it, so a flurry of events collapses into one.&lt;/p&gt;

&lt;p&gt;&lt;a id="footnote-4"&gt;4&lt;/a&gt;: &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API" rel="noopener noreferrer"&gt;Web Workers&lt;/a&gt; are the browser's primitive for running scripts on a background thread, communicating with the main thread only by passing messages. They have no direct access to the DOM, which is exactly what makes them safe for CPU-bound work like building and scanning an index: they cannot touch the page the user is interacting with even if they wanted to.&lt;/p&gt;

&lt;p&gt;&lt;a id="footnote-5"&gt;5&lt;/a&gt;: This is a classic out-of-order-response race. The cleanup-flag pattern shown here is the idiomatic way to handle it with React effects: when the query changes, it runs the previous effect's cleanup (flipping &lt;code&gt;cancelled&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt;) before starting the next, so a late-arriving response from a superseded query is discarded rather than rendered.&lt;/p&gt;



</description>
      <category>ai</category>
      <category>programming</category>
      <category>tutorial</category>
      <category>architecture</category>
    </item>
    <item>
      <title>My journey in data visualization, and how it took me to Altair</title>
      <dc:creator>Samuel Chan</dc:creator>
      <pubDate>Thu, 27 Oct 2022 10:44:16 +0000</pubDate>
      <link>https://dev.to/onlyphantom/my-journey-in-data-visualization-and-how-it-took-me-to-altair-3p3p</link>
      <guid>https://dev.to/onlyphantom/my-journey-in-data-visualization-and-how-it-took-me-to-altair-3p3p</guid>
      <description>&lt;p&gt;It is remarkable how far we've come in terms of python tooling for charting and visualization. I came into the python scientific computing ecosystem from R (user since 2015) and Matlab (user since 2013), and my experience has been largely pleasant. I wish I could say I've never looked back since, but the truth is that I have. For all the envy of Python's enormous ecosystem, scientific graphing in Matplotlib never felt as natural as it is with Matlab. This is a cruel irony as Matplotlib borrows heavily from the syntax of Matlab, a fact that isn't exactly subtle when you learn that the "Mat" in "Matplotlib" refers to Matlab and the library retains many of Matlab's plotting terminologies (i.e "markers").&lt;/p&gt;

&lt;h2&gt;
  
  
  My introduction to &lt;code&gt;ggplot2&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqmipem445ae2dxce2cw6.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqmipem445ae2dxce2cw6.jpg" alt="Some plots I've created with ggplot" width="800" height="630"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi516m7iqzcig2yshrfv6.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi516m7iqzcig2yshrfv6.jpg" alt="Plots I've created with ggplot" width="800" height="584"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Attached above are some of the images I've created in R, with the trusty &lt;code&gt;ggplot2&lt;/code&gt; library.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I remember how I came across Hadley Wickham's &lt;code&gt;ggplot2&lt;/code&gt;. I was relatively new to R and similar to Matlab, R has a rich feature set of built-in graphing utilities, catering to scientific computing users who needed to model complex simulations to statistical user who want a one-liner to produce a faceted plot (R users fondly call them "trellis plot"). Similar to Matlab, one didn't have to bother with importing external packages as R's core plotting library offers a lot out of the box.&lt;/p&gt;

&lt;p&gt;Then I came across a &lt;a href="https://github.com/onlyphantom/rgraphics" rel="noopener noreferrer"&gt;challenge by Harvard's IQSS&lt;/a&gt; and I was rather intrigued. The challenge was about reproducing &lt;a href="http://www.economist.com/node/21541178" rel="noopener noreferrer"&gt;this plot on the Economist&lt;/a&gt; and the materials presented an introduction to ggplot2. I completed the challenge that weekend, and went ahead to reproduce a few more plots I found from the Economist as practice exercise, including &lt;a href="https://github.com/onlyphantom/safeskies" rel="noopener noreferrer"&gt;this one&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz0crwgjd22oiurfy1k7i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz0crwgjd22oiurfy1k7i.png" alt="More images I created with ggplot" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ggplot2&lt;/code&gt; is fantastic. It is ergonomic, natural, and intuitive. It is all of the above because it is an implementation of the &lt;a href="https://vita.had.co.nz/papers/layered-grammar.html" rel="noopener noreferrer"&gt;grammar of graphics&lt;/a&gt;, which aims to bring a set of grammar to the art of the visualization. &lt;/p&gt;

&lt;p&gt;&lt;code&gt;ggplot2&lt;/code&gt; was great, and I duly put my newfound skills to use by combining it with the &lt;code&gt;shiny&lt;/code&gt; framework to produce visualization-heavy web dashboards like &lt;a href="https://samuelc.shinyapps.io/Quadrant" rel="noopener noreferrer"&gt;this one&lt;/a&gt;. When I taught data visualization, I &lt;a href="https://github.com/onlyphantom/darkershiny" rel="noopener noreferrer"&gt;only&lt;/a&gt; &lt;a href="https://github.com/onlyphantom/coronavirus" rel="noopener noreferrer"&gt;wanted&lt;/a&gt; &lt;a href="https://github.com/onlyphantom/textcomplete" rel="noopener noreferrer"&gt;to&lt;/a&gt; &lt;a href="https://github.com/onlyphantom/covidRT" rel="noopener noreferrer"&gt;teach&lt;/a&gt; the ggplot system because it felt methodical, logical and highly organized. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcopbokgt3j8dedxxa5uc.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcopbokgt3j8dedxxa5uc.jpg" alt="Some plots I've created with ggplot" width="800" height="633"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The teaching experience never quite translate equally to the python universe. It wasn't just because &lt;code&gt;ggplot2&lt;/code&gt; is highly declarative -- the user is concerned only with "what" she wants to achieve, not "how" she wants it achieved. It wasn't just because &lt;code&gt;ggplot2&lt;/code&gt; adheres to a coherent system, known as the grammar of graphics. It wasn't just because &lt;code&gt;ggplot2&lt;/code&gt; is highly instructive -- it picks default values in ways that are seemingly minor but when compounded over many decisions, can mean the difference between an abhorrent mess and a convincing medium of message.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo4tye10dg8ruzcgtu4g0.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo4tye10dg8ruzcgtu4g0.jpg" alt="ggplot images" width="800" height="660"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Altair
&lt;/h2&gt;

&lt;p&gt;I know many Python developers, myself included, begrudgingly switch between the different ecosystems to get the best of what R and Python offer. Then I stumbled upon Altair in 2018, when Altair was in its early days (last few releases before the v2.0; current version is 3.x). I created my first &lt;a href="https://github.com/onlyphantom/pedagogy" rel="noopener noreferrer"&gt;web app with Altair&lt;/a&gt; and it is a project that I still maintain as it is &lt;a href="http://pedagogyapp.com" rel="noopener noreferrer"&gt;actively used&lt;/a&gt; among our teaching staffs at &lt;a href="https://algorit.ma" rel="noopener noreferrer"&gt;Algoritma Data Science Education Center&lt;/a&gt;, a company I co-founded 6 years ago. &lt;/p&gt;

&lt;p&gt;Altair is Jake Vanderplas's attempt to provide a Python wrapper over Vega-Lite / Vega.js (which in turn, is built on D3.js). &lt;/p&gt;

&lt;p&gt;It adheres to the same grammar of graphics system. It is declarative. It is extensible and customizable. &lt;/p&gt;

&lt;p&gt;On top of that, it does something that &lt;code&gt;ggplot2&lt;/code&gt; library couldn't: it emits JavaScript code, which means your graphics can be embedded in just about any HTML file. It runs in the browser and can support browser events like &lt;code&gt;onMouseOver&lt;/code&gt;, &lt;code&gt;onMouseOut&lt;/code&gt;, &lt;code&gt;onClick&lt;/code&gt; etc, so charts created with Vega have that added interactivity and event binding that R plots couldn't. &lt;/p&gt;

&lt;p&gt;This year, when Anaconda announced PyScript, I started a playlist that I initially named &lt;a href="https://www.youtube.com/playlist?list=PLXsFtK46HZxXS9yBHkQXvaw1eLSIS5Mb-" rel="noopener noreferrer"&gt;build with PyScript&lt;/a&gt; in which I build one web app per video, showcasing the different possibilities that come with it. In many of the interactive web dashboards throughout the PyScript series I have relied on the trusty Altair library:&lt;br&gt;
    - &lt;a href="https://youtu.be/3mfqTGs05cE" rel="noopener noreferrer"&gt;Visualizing MiBand runs&lt;/a&gt;&lt;br&gt;
    - &lt;a href="https://youtu.be/ugSBaOT0rVI" rel="noopener noreferrer"&gt;Building an interactive data visualization dashboard&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;These videos demonstrates the utility of PyScript along with Altair to create user-friendly front-ends that are truly interactive and fun-to-use. Viewers love them, but I started to get comments that request for a separate series that cast the light on Altair. One that more centrally focus on the building blocks of Altair as a data visualization library. &lt;/p&gt;

&lt;p&gt;With this, I spent the last two months conceptualizing and developing a video series that goes into the art and science of data visualization in Python, adopted from the data visualization curriculum developed by the University of Washington. The series will consist of 5 to 8 long-form videos, each focusing on a key aspect of data visualization and the accompanying mental models that go with it. &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/playlist?list=PLXsFtK46HZxXBddVC0FqmbGdlvbDbaqzx" rel="noopener noreferrer"&gt;Data Visualization in Python (2022)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's part 1 of the video:&lt;br&gt;
&lt;a href="https://youtu.be/umTwkgQoo_E" rel="noopener noreferrer"&gt;Data Visualization in Python: Altair 4.2 (altair-viz) Tutorial&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Teaching visualization with Altair has been highly satisfying, and the data scientists &lt;a href="https://supertype.ai" rel="noopener noreferrer"&gt;where I work at&lt;/a&gt; are in agreement of its versatility; documenting my journey here so there is a point of reference and book-keeping for my future self has been equally refreshing. &lt;/p&gt;

&lt;p&gt;Now, readers, if you'd love sharing with me, how do you decide on your data visualization / charting toolkit and how does it look like?&lt;/p&gt;

</description>
      <category>python</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Interactive Visualization of my Mi Band ⌚️ runs w/ PyScript</title>
      <dc:creator>Samuel Chan</dc:creator>
      <pubDate>Fri, 15 Jul 2022 13:24:18 +0000</pubDate>
      <link>https://dev.to/onlyphantom/interactive-visualization-of-my-mi-band-runs-w-pyscript-23ke</link>
      <guid>https://dev.to/onlyphantom/interactive-visualization-of-my-mi-band-runs-w-pyscript-23ke</guid>
      <description>&lt;p&gt;I’ve heard this fantastic quote from a podcast that goes something alone the lines of &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Covid, it’s safe to say, will be here temporarily and then goes away completely. But some of the damages it causes, will stay with us probably permanently, certainly with far longer consequences, long after covid has moved on.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It could have been Balaji Srinivasan, it could have been somebody else.&lt;/p&gt;

&lt;p&gt;But it was the wake up call I needed. Being in this line of work as the rest of you, I am quite used to being glued to my chair working away for up to 10 hours a day. Then the pandemic struck.&lt;/p&gt;

&lt;p&gt;I transported my work machines from office to home second week into the pandemic. This is promptly followed by 14-hour work days, and a complete obliteration of my health. I put on weight, and my already pitiful fitness level tanked to virtually non-existent. &lt;/p&gt;

&lt;p&gt;Hearing that line, the thought that covid would take a few years off my lifespan sounds completely logical, and even inevitable. &lt;/p&gt;

&lt;p&gt;So I started running. And my best friend and co-founder at work bought me Mi Band, a fitness tracker. "A form of encouragement" is how he calls it. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsrilrytfjf88979wqnb4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsrilrytfjf88979wqnb4.png" alt="mi band transparent bg" width="500" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I kept at it for more than a year, and this week I thought to export the data and visualize my runs. The goal is to develop a responsive, mobile friendly dashboard that summarizes my running progress, milestones and personal best(s). &lt;/p&gt;

&lt;p&gt;I chose to do it in PyScript and deployed the web app using GitHub pages. Since I have a video series on building apps with PyScript, I naturally recorded the whole development process and wanted to share this with my audience. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffbu2v3vbkmwqkye4uda9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffbu2v3vbkmwqkye4uda9.png" alt="Export Mi Fit Data to Dashboard" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The tutorial features data wrangling and cleaning process using the &lt;code&gt;pandas&lt;/code&gt; library, before delegating the visualization to &lt;code&gt;altair&lt;/code&gt;, which is a wrapper over the popular &lt;code&gt;vega-lite&lt;/code&gt; (vega) charting library. As with all videos in the PyScript tutorial series, we deploy it through GitHub Pages. &lt;/p&gt;

&lt;p&gt;Here's the live dashboard:&lt;br&gt;
onlyphantom.github.io/miband/&lt;/p&gt;

&lt;p&gt;Here's all the code and data:&lt;br&gt;
&lt;a href="https://github.com/onlyphantom/miband" rel="noopener noreferrer"&gt;https://github.com/onlyphantom/miband&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's each video in the PyScript tutorial series:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Part 1 &lt;a href="https://youtu.be/Qo8dXyKXyME" rel="noopener noreferrer"&gt;PyScript Demo / Tutorial&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 2 &lt;a href="https://youtu.be/oH_rTTDjMvM" rel="noopener noreferrer"&gt;Deploying a PyScript App w/GitHub&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 3 &lt;a href="https://youtu.be/ugSBaOT0rVI" rel="noopener noreferrer"&gt;Build a PyScript interactive dashboard w/Altair&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 4 &lt;a href="https://youtu.be/H6rNzQeryQo" rel="noopener noreferrer"&gt;Build a PyScript guestbook webapp, CRUD&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 5 &lt;a href="https://youtu.be/eqyN8_diCAY" rel="noopener noreferrer"&gt;Building an interactive map w/ Folium &amp;amp; PyScript&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 6 &lt;a href="https://youtu.be/3mfqTGs05cE" rel="noopener noreferrer"&gt;Dashboard to visualize Mi Fit runs with PyScript + Altair (⌚ -🏃)&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So if also use a fitness tracker / wristband, and don't mind picking up &lt;code&gt;pandas&lt;/code&gt; or &lt;code&gt;altair&lt;/code&gt; for some personal dashboard-ing, follow along the tutorial! It's extremely satisfying to have a longer-term perspective on your run performances and I hope it helps, even if just a little bit, in helping you stay in shape. &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Part 6 &lt;a href="https://youtu.be/3mfqTGs05cE" rel="noopener noreferrer"&gt;Dashboard to visualize Mi Fit runs with PyScript + Altair (⌚ -🏃)&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;See you on YouTube!&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>datascience</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Todo + gamification with Taskwarrior &amp; Taskquant</title>
      <dc:creator>Samuel Chan</dc:creator>
      <pubDate>Wed, 06 Apr 2022 13:59:21 +0000</pubDate>
      <link>https://dev.to/onlyphantom/todo-gamification-with-taskwarrior-taskquant-3e38</link>
      <guid>https://dev.to/onlyphantom/todo-gamification-with-taskwarrior-taskquant-3e38</guid>
      <description>&lt;p&gt;If you live and breathe in the terminal, you want as little context switching as possible. For many things (like web browsing and spreadsheet editing) a graphical user interface seems inevitable no matter how great the alternative is (i.e &lt;em&gt;lynx browser&lt;/em&gt;). &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuw46pu3croz6yj7hfmjf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuw46pu3croz6yj7hfmjf.png" alt="gui and terminal" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For everything else, there is almost always a better option, one that allows you to stay within the terminal and &lt;em&gt;gasp&lt;/em&gt; optionally works with VIM bindings. As I review my current work and processes, it strikes me that a task manager (todo app) is one such use-case. &lt;/p&gt;

&lt;p&gt;A good todo app is stunning, aesthetically pleasing, and joyous to use. &lt;/p&gt;

&lt;p&gt;But a &lt;strong&gt;great&lt;/strong&gt; todo app should be almost the entirely opposite of a good todo app. It shouldn't even be joyous to use, lest you spend too much time in it. I want a todo app that is so minimal, so unobtrusive that it's &lt;em&gt;barely there&lt;/em&gt;. If I find the todo app enjoyable, I'm spending too much time in it instead of doing the task.&lt;/p&gt;

&lt;h4&gt;
  
  
  Taskwarrior
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frb7mzdesmdw8wav8h3gn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frb7mzdesmdw8wav8h3gn.png" alt="taskwarrior" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Taskwarrior is a free, open source TUI that puts your TODO in the terminal and is as unobtrusive as to-do apps get. &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=cDYIes9avW4" rel="noopener noreferrer"&gt;Taskwarrior video introduction&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It has built-in reporting features (&lt;code&gt;task burndown.weekly&lt;/code&gt;), a tagging system (&lt;code&gt;+meeting +report&lt;/code&gt;), a context switcher (&lt;code&gt;task context office&lt;/code&gt;), project hierarchies (&lt;code&gt;task project:Startup&lt;/code&gt;), a &lt;a href="https://taskwarrior.org/docs/using_dates.html" rel="noopener noreferrer"&gt;robust date system&lt;/a&gt; that blows anything, free or proprietary, out of the water, and a &lt;em&gt;scriptable&lt;/em&gt; urgency formula for each task.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;There are many other real-life examples of algorithms that are used to approximate qualities: the Apgar Score that assesses the health of newborns, Google's Page Rank that measures the importance of a web link, or credit ratings. These examples all approximate, but cannot perfectly represent, a complex concept.&lt;/p&gt;

&lt;p&gt;Taskwarrior uses a polynomial expression to calculate urgency. This is a simple sum of terms, where each term represents some measurable quality of a task. &lt;/p&gt;
&lt;/blockquote&gt;

&lt;h4&gt;
  
  
  Custom Attributes
&lt;/h4&gt;

&lt;p&gt;The best part of Taskwarrior is its support for user-defined attributes (UDA) beyond what it offers out of the box. No matter what productivity system (e.g GTD system) I choose, I &lt;em&gt;can make it work&lt;/em&gt; the way I want to with a bit of customization. &lt;/p&gt;

&lt;p&gt;One theme of my productivity system over the past many years is a way to quantitatively measure my productivity with a &lt;code&gt;score&lt;/code&gt; attribute. Different apps may come and go, but to make it work for me, I need the &lt;code&gt;score&lt;/code&gt; attribute as it's integral to how I manage my periodic reviews and longer-term reflections. It also pushes me to work on large projects since they come with bigger rewards (at least psychologically). &lt;/p&gt;

&lt;p&gt;How do I add that to Taskwarrior? Simple. Add the following lines to the end of your &lt;code&gt;.taskrc&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uda.score.type=numeric
uda.score.label=Score 🏆 
urgency.uda.score.coefficient=2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now all your task has an extra, optional &lt;code&gt;score&lt;/code&gt; field (🏆)!&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6np2dlrq4zxt5e4xxdh5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6np2dlrq4zxt5e4xxdh5.png" alt="taskwarrior score" width="800" height="455"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h4&gt;
  
  
  Productivity Scoresheet
&lt;/h4&gt;

&lt;p&gt;One thing I often do in my periodic review is to collect data-points on  my activities over the last period. This includes data from my work machines, my Mi Band, and Apple Health. &lt;/p&gt;

&lt;p&gt;For example, I know how many hours and minutes I've spent on one specific app, or task, over the last calendar year.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F39paphp708zgeg85lais.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F39paphp708zgeg85lais.jpg" alt="timing app" width="800" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For Taskwarrior, all that is left to do is to roll out a Python app that I can install and run. This package weighs less than 8kb (which is really &lt;em&gt;nothing&lt;/em&gt;) and is available on PyPI. It installs under one second:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pip install taskquant
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After which you can issue &lt;code&gt;tq&lt;/code&gt; to get a productivity breakdown. Add the &lt;code&gt;-w&lt;/code&gt; flag to have it grouped by week, in a rolling sum fashion:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tq -w 
+-------+-------+------------+
| Week# | Score | Cumulative |
+-------+-------+------------+
|  10   |  56   |     56     |
|  11   |  33   |     89     |
|  12   |  26   |    115     |
|  13   |  12   |    127     |
+-------+-------+------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F34tv82rng75kce1qwo2n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F34tv82rng75kce1qwo2n.png" alt="taskquant" width="799" height="445"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you want to hack away at your productivity system, the project is available on my GitHub:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/onlyphantom/taskquant" rel="noopener noreferrer"&gt;TaskQuant&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Bonus: Watch the whole process in action
&lt;/h4&gt;

&lt;p&gt;I thought it'd be interesting to show how I build out the CLI tool so I've uploaded a recording of that on YouTube as well. If you're a data analyst from the &lt;code&gt;pandas&lt;/code&gt; world, you may learn a thing or two about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;group by&lt;/li&gt;
&lt;li&gt;aggregation&lt;/li&gt;
&lt;li&gt;filling missing dates (padding)&lt;/li&gt;
&lt;li&gt;tabular summary&lt;/li&gt;
&lt;li&gt;&lt;p&gt;colorful CLI &lt;br&gt;
All without any dependencies except tasklib (the Taskwarrior libary itself). This brings the package down to 6.8kb of code, and with its 1 second installation time, is similarly unobtrusive. Gets you your productivity scores, and get out of the way. If you'd like to watch it, here are the links:&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=lT2jqmhRkxo" rel="noopener noreferrer"&gt;Building Taskquant ep.1&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://youtube.com/playlist?list=PLXsFtK46HZxXIVE4tRjwMjwKFVaQSdT5W" rel="noopener noreferrer"&gt;Building Taskquant, full playlist&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  What's next
&lt;/h4&gt;

&lt;p&gt;We'll add terminal-based graphs and charts, and maybe more variety in terms of reporting format. &lt;strong&gt;No, there can never be too many ways to see how you're not doing enough&lt;/strong&gt;. If you want to follow along the journey, drop by and 👋 on YouTube. &lt;/p&gt;

&lt;p&gt;If you're feeling more driven than that, go ahead and install Taskwarrior and Taskquant and start knocking out some to-do! Collaborators welcomed!&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>python</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
