<?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: gyani</title>
    <description>The latest articles on DEV Community by gyani (@ggyanie).</description>
    <link>https://dev.to/ggyanie</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3933445%2Fdb95e6da-0e56-463e-9ec5-439be36878dc.png</url>
      <title>DEV Community: gyani</title>
      <link>https://dev.to/ggyanie</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ggyanie"/>
    <language>en</language>
    <item>
      <title>The four-line cron that decides who falls in love (in my dating app)</title>
      <dc:creator>gyani</dc:creator>
      <pubDate>Wed, 27 May 2026 06:54:31 +0000</pubDate>
      <link>https://dev.to/ggyanie/the-four-line-cron-that-decides-who-falls-in-love-in-my-dating-app-bi8</link>
      <guid>https://dev.to/ggyanie/the-four-line-cron-that-decides-who-falls-in-love-in-my-dating-app-bi8</guid>
      <description>&lt;p&gt;I shipped a dating app five months ago. The matching engine is one Postgres function, a 100-line edge function, and a launchd job on my desk that hits a route every hour. No queue, no worker, no fancy ML stack. Here is the whole thing in order, and the small disaster that taught me to move the cron off Vercel.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a "match" actually is
&lt;/h2&gt;

&lt;p&gt;In most dating apps a match is a mutual swipe. In ours a match is a row in a &lt;code&gt;suggested_matches&lt;/code&gt; table with a score above a threshold. Two profiles, one float in &lt;code&gt;[0, 1]&lt;/code&gt;, and a reason that gets shown to both sides.&lt;/p&gt;

&lt;p&gt;The pipeline that creates that row is short.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;launchd (hourly, on my Mac)
   |
   v
GET /api/cron/generate-matches  (Next.js route, bearer-guarded)
   |
   v
Supabase Edge Function  (Deno, batches users)
   |
   v
pgvector ANN  (top 100 candidates per user, cosine over embedded prompts)
   |
   v
Linear scorer  (four hand-weighted features over the candidate set)
   |
   v
INSERT into suggested_matches  (above threshold only)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five steps. The interesting line is the rerank.&lt;/p&gt;

&lt;h2&gt;
  
  
  The candidate generation step (and why I let pgvector do it)
&lt;/h2&gt;

&lt;p&gt;Each user writes a small set of prompt responses on onboarding. We embed those responses with a single embedding call. That vector is one row in &lt;code&gt;vibe_profiles&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Generating candidates for one user is then literally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;cosine_distance&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;vibe_profiles&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;_user_id&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;completed_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;intent_overlap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;_intents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;intents&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;0&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;lt;=&amp;gt;&lt;/code&gt; is pgvector's cosine distance operator. Smaller is closer. &lt;code&gt;intent_overlap&lt;/code&gt; is a Postgres function that returns the size of the intersection between two intent arrays (relationship, friendship, community).&lt;/p&gt;

&lt;p&gt;I do not run ANN search myself. I do not pre-cluster. I do not maintain a separate vector store. pgvector handles the index, the operator, the query plan, the lot. The whole "candidate generation" layer that other dating apps build entire microservices for is one ORDER BY clause.&lt;/p&gt;

&lt;p&gt;This was the first decision that surprised me about how cheap the whole thing turned out to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rerank step is four weighted features
&lt;/h2&gt;

&lt;p&gt;Cosine alone is a good first pass and a bad final answer. Two profiles can be vector-near because both write reflectively and recurse on the same word, even if their actual lives have no overlap. So the top-100 candidates get rescored.&lt;/p&gt;

&lt;p&gt;The rescorer is a linear function over four features, each in &lt;code&gt;[0, 1]&lt;/code&gt;:&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;function&lt;/span&gt; &lt;span class="nf"&gt;vibeScore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Profile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Profile&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sim&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="nf"&gt;cosineDistance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vec&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;intent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;intentOverlap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;intents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;intents&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;cad&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cadenceMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cadence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cadence&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;geo&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;geoFit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;geo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;geo&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.55&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;sim&lt;/span&gt;
       &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.20&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;intent&lt;/span&gt;
       &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;cad&lt;/span&gt;
       &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;geo&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;That is the whole matcher.&lt;/p&gt;

&lt;p&gt;Some notes on the weights:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;sim&lt;/code&gt; carries the most weight because the prompt embedding is doing the real semantic work. The other three are guards.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;intent&lt;/code&gt; is binary-ish in practice: if you are here for community and I am here for a relationship, the overlap is small and the score collapses.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cad&lt;/code&gt; (cadence) is a derived feature from how long a user takes to write a single prompt response. It is a very weak proxy for "how this person uses written language", but it correlates surprisingly well with whether a thread between two users sustains past day three. Worth its 15%.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;geo&lt;/code&gt; is intentionally last and intentionally small. Most users care less about distance than they tell themselves they do, and weighting it more produces matches that are geographically convenient and texturally identical.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I tuned these by hand against the first ~50 matches that produced sustained threads, not by training a model. The set was too small for anything else. I will probably keep it that way until the set is too big for me to read in an afternoon, and even then I will resist.&lt;/p&gt;

&lt;h2&gt;
  
  
  The insert step is two lines
&lt;/h2&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;above&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;scored&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;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.45&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;suggested_matches&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;above&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Threshold 0.45 was empirically the floor below which users stopped reaching out. There is no clever pruning beyond that. Upsert handles the case where the same pair gets surfaced by both directions of the cron in the same window.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cron is where I burned a day
&lt;/h2&gt;

&lt;p&gt;This is the part that humbled me.&lt;/p&gt;

&lt;p&gt;When I wrote the Vercel cron entry, I set the schedule to &lt;code&gt;0 * * * *&lt;/code&gt; (every hour at the top of the hour). The Vercel CLI accepted it locally. The build then rejected it with a quiet error because hourly crons are not on the Hobby plan. Worse, the rejection blocked the deploy. Every subsequent push hung in the build queue with a confusing error. I had a stack of essays sitting in PRs that I could not figure out why were not landing.&lt;/p&gt;

&lt;p&gt;I burned half a day before I tracked it down. The fix in the end was two parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Revert the Vercel cron to daily (&lt;code&gt;0 0 * * *&lt;/code&gt;) so deploys flow again. Keep the function exactly as it is.&lt;/li&gt;
&lt;li&gt;Trigger the function from my own machine, hourly, via launchd:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   &lt;span class="c"&gt;# scripts/matches_hourly.sh&lt;/span&gt;
   curl &lt;span class="nt"&gt;-X&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$INTERNAL_CRON_SECRET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     https://byvibration.com/api/cron/generate-matches
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;   &lt;span class="c"&gt;&amp;lt;!-- ~/Library/LaunchAgents/com.byvibration.matches-hourly.plist --&amp;gt;&lt;/span&gt;
   &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StartCalendarInterval&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
   &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;0&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Vercel daily cron stays as a fallback for when the Mac is off. The hourly cadence comes from my own machine.&lt;/p&gt;

&lt;p&gt;This feels janky in writing. It is fine in practice. A &lt;code&gt;launchd&lt;/code&gt; entry on a Mac that is plugged in and caffeinated is more reliable than a Vercel cron on the free tier, and crucially it does not block deploys. The whole story of moving a cron off a hosted platform took thirty minutes, and the only thing it required was admitting that "real" infrastructure is not always the one with the prettier dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from the build
&lt;/h2&gt;

&lt;p&gt;Three things stayed with me after this pipeline landed.&lt;/p&gt;

&lt;p&gt;The first is that the matcher is much smaller than people assume. Four features and a vector op. The intelligence is in the prompts the user writes, not in the math the engine does on top of them. If your matching layer is a regression model with 80 features, you are matching on noise.&lt;/p&gt;

&lt;p&gt;The second is that cosine + a tiny linear rerank gets you a long way before you need to reach for anything heavier. The temptation to put a transformer-shaped thing in the rerank is real and almost always premature. Cosine over good prompt embeddings is already doing more lifting than any small model you would slot in.&lt;/p&gt;

&lt;p&gt;The third is that you should put the cron where your deploys do not have to talk to it. The amount of incidental fragility you remove by detaching scheduled jobs from your hosting platform is genuinely surprising.&lt;/p&gt;




&lt;p&gt;I work on &lt;a href="https://byvibration.com" rel="noopener noreferrer"&gt;byvibration&lt;/a&gt;, a dating and friendship app that matches by what people write, not by photos. The whole matcher described above is in the &lt;a href="https://github.com/donnowyu/soulmate-core" rel="noopener noreferrer"&gt;soulmate-core&lt;/a&gt; repo (MIT, 65 passing tests). If any of this resonates and you want to see how the four-feature rerank reads on real prompts, that is what the live site does.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>automation</category>
      <category>postgres</category>
      <category>showdev</category>
    </item>
    <item>
      <title>How a photo-blind dating engine actually ranks people (the TypeScript)</title>
      <dc:creator>gyani</dc:creator>
      <pubDate>Tue, 26 May 2026 07:08:27 +0000</pubDate>
      <link>https://dev.to/ggyanie/how-a-photo-blind-dating-engine-actually-ranks-people-the-typescript-p4e</link>
      <guid>https://dev.to/ggyanie/how-a-photo-blind-dating-engine-actually-ranks-people-the-typescript-p4e</guid>
      <description>&lt;p&gt;Last post I argued that the matcher in our dating app cannot read photos because the TypeScript types make it impossible. A few people asked the obvious follow-up. If the matcher never sees a face, what does it see, and how does it decide who you should meet this week?&lt;/p&gt;

&lt;p&gt;This post is that. Code samples, vector math, the one heuristic that does most of the work, and the three things we explicitly chose not to do. Repo is at github.com/donnowyu/soulmate-core, MIT.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing the matcher actually sees
&lt;/h2&gt;

&lt;p&gt;A profile, in the eyes of the ranker, is this:&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;type&lt;/span&gt; &lt;span class="nx"&gt;Profile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;prompts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PromptAnswers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// five short text answers&lt;/span&gt;
  &lt;span class="nl"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VoiceTranscript&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// ~30s recording, kept as text&lt;/span&gt;
  &lt;span class="nl"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Intent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// 'friendship' | 'relationship' | 'community'&lt;/span&gt;
  &lt;span class="nl"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ProfileMeta&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// age band, language, city, locale&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No photo field. No height. No income. No "tags." The strongest input by mass is the prompts plus the voice transcript, which together produce somewhere between 800 and 2,500 tokens of free-form text about how this person actually thinks.&lt;/p&gt;

&lt;p&gt;That text is the matching substrate. Everything downstream is a function of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: turn text into a vector
&lt;/h2&gt;

&lt;p&gt;We embed the concatenated prompts-plus-voice into a fixed-size vector using a text embedding model. The exact provider does not matter much. We use OpenAI's &lt;code&gt;text-embedding-3-small&lt;/code&gt; (1536 dims) because it is cheap, multilingual, and good enough that the rest of the system survives provider churn.&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;// soulmate-core/src/embed.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;embedProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Profile&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Vector&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;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;formatForEmbedding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&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;data&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="nx"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text-embedding-3-small&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&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;data&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="nx"&gt;embedding&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Vector&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;formatForEmbedding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Profile&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prompts&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&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="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&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 vector is what gets stored in Postgres, in a column typed &lt;code&gt;vector(1536)&lt;/code&gt; thanks to &lt;code&gt;pgvector&lt;/code&gt;. The profile row also stores the prompts and the voice transcript for display, but the matcher reads the vector and only the vector. Whatever else lives on the row is not in the function signature, so the compiler cannot accidentally let it leak in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: find candidates with pgvector
&lt;/h2&gt;

&lt;p&gt;Given a viewer with embedded vector &lt;code&gt;v&lt;/code&gt;, the candidate query is a cosine-distance ANN lookup, filtered by intent overlap and a completed-profile gate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;distance&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;profiles&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;completed_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;intents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;target_id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;blocks&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;actor_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;lt;=&amp;gt;&lt;/code&gt; is the pgvector cosine-distance operator. The index is an HNSW on the embedding column so that "100 nearest" runs in milliseconds even at 100k+ profiles. Smaller-distance is more similar, since cosine-distance is &lt;code&gt;1 - cosine-similarity&lt;/code&gt; and the operator returns the distance form.&lt;/p&gt;

&lt;p&gt;Two things to notice. First, the SQL itself reads no photo data. There is no photo table in this join. Second, the candidate set is bounded to 100. The ranker never sees more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: rank the 100 with a more expensive signal
&lt;/h2&gt;

&lt;p&gt;Cosine distance on embeddings is the cheap pass. It is right about taste, off about intent depth. Two people can write similarly and want very different things. So we re-rank the 100 with a second function that does not call an LLM but does look at structured signals the embedding tends to flatten.&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;// soulmate-core/src/rank.ts&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;rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;viewer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Profile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Profile&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;textSim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;viewer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;              &lt;span class="c1"&gt;// 0..1&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;intent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;intentOverlap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;viewer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// 0..1&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;energy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;energyMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;viewer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// 0..1&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cadence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cadenceMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;viewer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// 0..1&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;text&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.55&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="nx"&gt;intent&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="nx"&gt;energy&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="nx"&gt;cadence&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.08&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;&lt;code&gt;textSim&lt;/code&gt; is the cosine similarity reconstructed from the distance returned by Postgres. &lt;code&gt;intentOverlap&lt;/code&gt; weighs whether both sides want the same kind of connection (friendship, relationship, community), and how strongly. &lt;code&gt;energyMatch&lt;/code&gt; and &lt;code&gt;cadenceMatch&lt;/code&gt; are small heuristics derived from how much text the person wrote and how fast they answer messages historically. They mostly catch the case where two people are similar on substance but operate on incompatible rhythms.&lt;/p&gt;

&lt;p&gt;The weights are not fitted. They are intuitions we did not have data to fit yet, and we kept them in code so any future change is a real diff and not a parameter twiddle that nobody notices. When we have enough signal to fit them, we will, and that PR will be reviewable in one page.&lt;/p&gt;

&lt;p&gt;The function returns one float. We pick the top 5 above a 0.45 threshold for the weekly batch. If fewer than 5 cross the threshold, we send fewer. We do not pad.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we explicitly did not do
&lt;/h2&gt;

&lt;p&gt;Three things kept coming up in design review and we kept choosing not to.&lt;/p&gt;

&lt;p&gt;We did not build a feed. There is no infinite-scroll candidate stream in this product. The weekly batch is the whole surface. The argument for a feed is engagement; we are intentionally trading engagement for a different shape of behavior, the one where the user opens the app rarely and deliberately.&lt;/p&gt;

&lt;p&gt;We did not let the matcher see photos, not even as a tiebreaker. We considered the version where photos enter at rank time with a small weight, and rejected it for the obvious type-system reason and the less obvious behavioral one: as soon as the matcher can see faces, the production data collection of "what humans clicked on" starts encoding face preference into the ranker even if no explicit feature does. The cleanest defense is to make the photo bytes literally unreachable from the function. The compiler is the policy.&lt;/p&gt;

&lt;p&gt;We did not put an LLM in the ranker. The temptation is real, especially since we are already embedding text. We resisted because an LLM in the loop makes the function opaque in a way that the four-feature linear combination is not. If a match is wrong, we can read the four numbers. We cannot read an LLM the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters outside dating
&lt;/h2&gt;

&lt;p&gt;The pattern, embedding-plus-pgvector-plus-small-linear-rerank, is good for any product where the primary signal is "how this user thinks" rather than "what this user clicked on." Documentation search, similar-issue triage, mentor matching, study-group formation. The dating context is just the one where the cost of being wrong is most visible to the user.&lt;/p&gt;

&lt;p&gt;If you want to read the full implementation, it is at github.com/donnowyu/soulmate-core, all of it under MIT. The vector math is in &lt;code&gt;src/rank.ts&lt;/code&gt; and &lt;code&gt;src/embed.ts&lt;/code&gt;; the SQL is in &lt;code&gt;db/migrations/&lt;/code&gt;. Tests cover the rank function and the edge cases of empty profiles, missing voices, and intent mismatch.&lt;/p&gt;

&lt;p&gt;The product that wraps this engine is byvibration.com. It is the same idea taken all the way to a working app: you write, the engine reads how you think, you meet by mind not by face.&lt;/p&gt;

&lt;p&gt;I work on byvibration. The framework above stands on its own; the product is one way to live inside it.---&lt;br&gt;
title: "The four-line cron that decides who falls in love (in my dating app)"&lt;br&gt;
published: true&lt;br&gt;
canonical_url: &lt;a href="https://byvibration.com/essays/why-matching-layer-is-physically-blind" rel="noopener noreferrer"&gt;https://byvibration.com/essays/why-matching-layer-is-physically-blind&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  tags: typescript, postgres, webdev, supabase
&lt;/h2&gt;

&lt;p&gt;I shipped a dating app five months ago. The matching engine is one Postgres function, a 100-line edge function, and a launchd job on my desk that hits a route every hour. No queue, no worker, no fancy ML stack. Here is the whole thing in order, and the small disaster that taught me to move the cron off Vercel.&lt;/p&gt;
&lt;h2&gt;
  
  
  What a "match" actually is
&lt;/h2&gt;

&lt;p&gt;In most dating apps a match is a mutual swipe. In ours a match is a row in a &lt;code&gt;suggested_matches&lt;/code&gt; table with a score above a threshold. Two profiles, one float in &lt;code&gt;[0, 1]&lt;/code&gt;, and a reason that gets shown to both sides.&lt;/p&gt;

&lt;p&gt;The pipeline that creates that row is short.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;launchd (hourly, on my Mac)
   |
   v
GET /api/cron/generate-matches  (Next.js route, bearer-guarded)
   |
   v
Supabase Edge Function  (Deno, batches users)
   |
   v
pgvector ANN  (top 100 candidates per user, cosine over embedded prompts)
   |
   v
Linear scorer  (four hand-weighted features over the candidate set)
   |
   v
INSERT into suggested_matches  (above threshold only)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five steps. The interesting line is the rerank.&lt;/p&gt;

&lt;h2&gt;
  
  
  The candidate generation step (and why I let pgvector do it)
&lt;/h2&gt;

&lt;p&gt;Each user writes a small set of prompt responses on onboarding. We embed those responses with a single embedding call. That vector is one row in &lt;code&gt;vibe_profiles&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Generating candidates for one user is then literally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;cosine_distance&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;vibe_profiles&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;_user_id&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;completed_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;intent_overlap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;_intents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;intents&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;0&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;lt;=&amp;gt;&lt;/code&gt; is pgvector's cosine distance operator. Smaller is closer. &lt;code&gt;intent_overlap&lt;/code&gt; is a Postgres function that returns the size of the intersection between two intent arrays (relationship, friendship, community).&lt;/p&gt;

&lt;p&gt;I do not run ANN search myself. I do not pre-cluster. I do not maintain a separate vector store. pgvector handles the index, the operator, the query plan, the lot. The whole "candidate generation" layer that other dating apps build entire microservices for is one ORDER BY clause.&lt;/p&gt;

&lt;p&gt;This was the first decision that surprised me about how cheap the whole thing turned out to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rerank step is four weighted features
&lt;/h2&gt;

&lt;p&gt;Cosine alone is a good first pass and a bad final answer. Two profiles can be vector-near because both write reflectively and recurse on the same word, even if their actual lives have no overlap. So the top-100 candidates get rescored.&lt;/p&gt;

&lt;p&gt;The rescorer is a linear function over four features, each in &lt;code&gt;[0, 1]&lt;/code&gt;:&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;function&lt;/span&gt; &lt;span class="nf"&gt;vibeScore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Profile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Profile&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sim&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="nf"&gt;cosineDistance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vec&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;intent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;intentOverlap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;intents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;intents&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;cad&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cadenceMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cadence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cadence&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;geo&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;geoFit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;geo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;geo&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.55&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;sim&lt;/span&gt;
       &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.20&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;intent&lt;/span&gt;
       &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;cad&lt;/span&gt;
       &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;geo&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;That is the whole matcher.&lt;/p&gt;

&lt;p&gt;Some notes on the weights:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;sim&lt;/code&gt; carries the most weight because the prompt embedding is doing the real semantic work. The other three are guards.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;intent&lt;/code&gt; is binary-ish in practice: if you are here for community and I am here for a relationship, the overlap is small and the score collapses.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cad&lt;/code&gt; (cadence) is a derived feature from how long a user takes to write a single prompt response. It is a very weak proxy for "how this person uses written language", but it correlates surprisingly well with whether a thread between two users sustains past day three. Worth its 15%.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;geo&lt;/code&gt; is intentionally last and intentionally small. Most users care less about distance than they tell themselves they do, and weighting it more produces matches that are geographically convenient and texturally identical.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I tuned these by hand against the first ~50 matches that produced sustained threads, not by training a model. The set was too small for anything else. I will probably keep it that way until the set is too big for me to read in an afternoon, and even then I will resist.&lt;/p&gt;

&lt;h2&gt;
  
  
  The insert step is two lines
&lt;/h2&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;above&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;scored&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;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.45&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;suggested_matches&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;above&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Threshold 0.45 was empirically the floor below which users stopped reaching out. There is no clever pruning beyond that. Upsert handles the case where the same pair gets surfaced by both directions of the cron in the same window.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cron is where I burned a day
&lt;/h2&gt;

&lt;p&gt;This is the part that humbled me.&lt;/p&gt;

&lt;p&gt;When I wrote the Vercel cron entry, I set the schedule to &lt;code&gt;0 * * * *&lt;/code&gt; (every hour at the top of the hour). The Vercel CLI accepted it locally. The build then rejected it with a quiet error because hourly crons are not on the Hobby plan. Worse, the rejection blocked the deploy. Every subsequent push hung in the build queue with a confusing error. I had a stack of essays sitting in PRs that I could not figure out why were not landing.&lt;/p&gt;

&lt;p&gt;I burned half a day before I tracked it down. The fix in the end was two parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Revert the Vercel cron to daily (&lt;code&gt;0 0 * * *&lt;/code&gt;) so deploys flow again. Keep the function exactly as it is.&lt;/li&gt;
&lt;li&gt;Trigger the function from my own machine, hourly, via launchd:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   &lt;span class="c"&gt;# scripts/matches_hourly.sh&lt;/span&gt;
   curl &lt;span class="nt"&gt;-X&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$INTERNAL_CRON_SECRET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     https://byvibration.com/api/cron/generate-matches
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;   &lt;span class="c"&gt;&amp;lt;!-- ~/Library/LaunchAgents/com.byvibration.matches-hourly.plist --&amp;gt;&lt;/span&gt;
   &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StartCalendarInterval&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
   &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;0&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Vercel daily cron stays as a fallback for when the Mac is off. The hourly cadence comes from my own machine.&lt;/p&gt;

&lt;p&gt;This feels janky in writing. It is fine in practice. A &lt;code&gt;launchd&lt;/code&gt; entry on a Mac that is plugged in and caffeinated is more reliable than a Vercel cron on the free tier, and crucially it does not block deploys. The whole story of moving a cron off a hosted platform took thirty minutes, and the only thing it required was admitting that "real" infrastructure is not always the one with the prettier dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from the build
&lt;/h2&gt;

&lt;p&gt;Three things stayed with me after this pipeline landed.&lt;/p&gt;

&lt;p&gt;The first is that the matcher is much smaller than people assume. Four features and a vector op. The intelligence is in the prompts the user writes, not in the math the engine does on top of them. If your matching layer is a regression model with 80 features, you are matching on noise.&lt;/p&gt;

&lt;p&gt;The second is that cosine + a tiny linear rerank gets you a long way before you need to reach for anything heavier. The temptation to put a transformer-shaped thing in the rerank is real and almost always premature. Cosine over good prompt embeddings is already doing more lifting than any small model you would slot in.&lt;/p&gt;

&lt;p&gt;The third is that you should put the cron where your deploys do not have to talk to it. The amount of incidental fragility you remove by detaching scheduled jobs from your hosting platform is genuinely surprising.&lt;/p&gt;




&lt;p&gt;I work on &lt;a href="https://byvibration.com" rel="noopener noreferrer"&gt;byvibration&lt;/a&gt;, a dating and friendship app that matches by what people write, not by photos. The whole matcher described above is in the &lt;a href="https://github.com/donnowyu/soulmate-core" rel="noopener noreferrer"&gt;soulmate-core&lt;/a&gt; repo (MIT, 65 passing tests). If any of this resonates and you want to see how the four-feature rerank reads on real prompts, that is what the live site does.&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>opensource</category>
      <category>showdev</category>
      <category>typescript</category>
    </item>
    <item>
      <title>65 deterministic tests for a matching engine, no database, no fixtures</title>
      <dc:creator>gyani</dc:creator>
      <pubDate>Tue, 19 May 2026 07:00:55 +0000</pubDate>
      <link>https://dev.to/ggyanie/65-deterministic-tests-for-a-matching-engine-no-database-no-fixtures-4cbd</link>
      <guid>https://dev.to/ggyanie/65-deterministic-tests-for-a-matching-engine-no-database-no-fixtures-4cbd</guid>
      <description>&lt;p&gt;I write the matching engine for a small dating and friendship app. The engine is open-source (github.com/donnowyu/soulmate-core, MIT). The hardest thing about working on it was not the math. It was that for the first three weeks I could not refactor anything because I had no way to know whether my changes had broken the ranking.&lt;/p&gt;

&lt;p&gt;This post is the testing pattern I landed on. 65 deterministic tests. No database. No fixtures loaded from disk. No live embeddings. The whole suite runs in 1.8 seconds and tells me, in plain language, whether any of fifteen ranking invariants just regressed.&lt;/p&gt;

&lt;p&gt;If you maintain anything that takes a query and returns a sorted list (search, recsys, matching, ranking, RAG retrieval), the shape of this should transfer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with the obvious test setup
&lt;/h2&gt;

&lt;p&gt;The first version of the test suite was the obvious one. Seed a fake Postgres, insert 200 fake profiles, compute embeddings with the real model, ask the engine to rank them, snapshot the top 20.&lt;/p&gt;

&lt;p&gt;Three problems showed up within a week.&lt;/p&gt;

&lt;p&gt;It was slow. Real embedding calls plus database round trips meant ~40 seconds for the full suite. I stopped running tests before commits.&lt;/p&gt;

&lt;p&gt;It was non-deterministic. The embedding model was probabilistic enough that the same input produced subtly different vectors across runs, which meant the snapshot diff was always noisy. I learned to ignore the diff. Predictably, the day a real regression slipped in I ignored that diff too.&lt;/p&gt;

&lt;p&gt;And the failures, when they came, did not tell me anything. A snapshot would change. I would stare at a list of 20 user IDs and try to remember what each one was supposed to represent. The test had nothing to say about why the new order was wrong, only that it was different.&lt;/p&gt;

&lt;h2&gt;
  
  
  What deterministic tests look like for a matching engine
&lt;/h2&gt;

&lt;p&gt;The flip was to stop testing the system end-to-end and start testing each invariant the ranker is supposed to satisfy, one at a time, with the smallest possible synthetic input.&lt;/p&gt;

&lt;p&gt;Here is one of the tests, verbatim.&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;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;intent overlap dominates lexical similarity&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;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;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;relationship&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;I like long walks&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;friendship&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;I like long walks and hiking&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;viewer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;relationship&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;I like hiking&lt;/span&gt;&lt;span class="dl"&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;ranked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;viewer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;expect&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;relationship&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;There is no database. &lt;code&gt;profile&lt;/code&gt; is a one-line helper that builds a Profile object with sensible defaults. &lt;code&gt;rank&lt;/code&gt; is the real function from the engine. The vectors are synthetic, produced by a tiny deterministic embedder (a stable hash of token bigrams projected into 128 dimensions), not the live model.&lt;/p&gt;

&lt;p&gt;The test reads, in English, as a sentence: "When two candidates have similar text but different intents, the one with matching intent should win." When that test fails, the diagnostic is not "snapshot differs." It is "rank()[0].intent was 'friendship', expected 'relationship'." That tells you exactly which invariant broke.&lt;/p&gt;

&lt;p&gt;There are 65 of these. Each one is roughly four to ten lines. Together they cover the fifteen ranking invariants the matcher is supposed to hold, with several tests per invariant for edge cases (empty fields, very short text, language mismatch, intent set to community, etc.).&lt;/p&gt;

&lt;h2&gt;
  
  
  How to keep them deterministic without faking the math
&lt;/h2&gt;

&lt;p&gt;The trick is the deterministic embedder. It is about thirty lines. It tokenizes the input the same way the real pipeline does, then projects each token into a fixed 128-dimensional vector using a stable hash. Two inputs with identical tokens produce identical vectors. Two inputs that share most tokens produce vectors with a high cosine similarity, just like a real embedder would.&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;function&lt;/span&gt; &lt;span class="nf"&gt;tinyEmbed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&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="nb"&gt;Float32Array&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;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Float32Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;128&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;tok&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;tokenize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&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;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;stableHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tok&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;let&lt;/span&gt; &lt;span class="nx"&gt;i&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&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;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&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="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="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="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&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 is not the right embedder for production. It cannot tell that "happy" and "joyful" are similar. But for testing the ranker, it is exactly right. The ranker should care about intent overlap, language match, profile-completeness, and recency before it cares about the small nuance the real embedder adds. The tests assert those four things hold even with the dumb embedder. If a refactor accidentally makes the engine depend on the embedder's semantic nuance for a basic invariant, the test catches it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why no database
&lt;/h2&gt;

&lt;p&gt;Every test runs against in-memory objects. The engine accepts a viewer profile and a list of candidate profiles, both as plain TypeScript values, and returns a ranked list. The Postgres + pgvector layer that production uses is a separate module that calls into this pure function. The tests do not touch it.&lt;/p&gt;

&lt;p&gt;This is the most expensive design decision in the whole repo. It means the ranker cannot do anything fancy with the database (no clever joins, no SQL-side scoring). Every signal it uses must travel through the function signature. In exchange the tests run in 1.8 seconds and have no flakiness. I have come back to this tradeoff many times. I keep choosing the tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this gets you
&lt;/h2&gt;

&lt;p&gt;Refactor confidence. I rewrote the rerank step three times in two months. Each rewrite, I ran the suite. If the tests passed, the rerank was at least as good as the previous version on every invariant we care about. If a test failed, the message told me exactly which invariant I had broken.&lt;/p&gt;

&lt;p&gt;Honest documentation. The test file is the most useful documentation of how the matcher behaves. New contributors read the tests first. The invariants are not in a wiki that will go stale, they are executable.&lt;/p&gt;

&lt;p&gt;A floor under quality. I cannot make a change that ships a worse ranker by accident. The bar is "all 65 invariants still hold." Below that bar, CI is red.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three things this does not test
&lt;/h2&gt;

&lt;p&gt;Snapshot tests are still useful for catching subtle changes in ranking order on real data. I have a separate test file with two of those, gated on a &lt;code&gt;RUN_INTEGRATION=1&lt;/code&gt; env var, that uses real embeddings and a small fixture set. They are slow and they are noisy. They are not the suite I run on every commit.&lt;/p&gt;

&lt;p&gt;Performance. The 65 tests do not check that ranking is fast. There is a separate benchmark file that runs nightly.&lt;/p&gt;

&lt;p&gt;Calibration. The tests check that the ranker orders correctly. They do not check that the score thresholds match what should be a "good match" for users. That is a product question and the answer comes from user data, not from invariant tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern, generalized
&lt;/h2&gt;

&lt;p&gt;If you maintain a system that takes a query plus a candidate set and returns a sorted list, the pattern is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;List the invariants the ranker should satisfy, in English. "When the user explicitly excludes a language, no candidate of that language should appear in the top N." Write that down.&lt;/li&gt;
&lt;li&gt;For each invariant, build a minimal synthetic input that exercises only it.&lt;/li&gt;
&lt;li&gt;Use a deterministic stand-in for any probabilistic component (embeddings, model calls). Make sure the stand-in is dumb enough that the invariant still has to be satisfied by the ranker, not by accident.&lt;/li&gt;
&lt;li&gt;Skip the persistence layer entirely. Treat the ranker as a pure function.&lt;/li&gt;
&lt;li&gt;Write the assertion in terms of the invariant, not in terms of which specific items came back in which order. "The top result should have intent X" not "the top result should be ID 47."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The reward is a test suite that tells you, in English, what you just broke. After enough refactors that survived it, the suite starts to feel like a colleague who has read every line of the engine and will let you know the moment you do something stupid.&lt;/p&gt;

&lt;p&gt;The full engine, all 65 tests, and the tiny deterministic embedder are at github.com/donnowyu/soulmate-core. If you ever want to see one specific test, &lt;code&gt;tests/rank.invariants.test.ts&lt;/code&gt; is the file. The engine ships on byvibration.com, which is a small relationship and friendship app that genuinely cannot read your photos.---&lt;br&gt;
title: 65 deterministic tests for a matching engine, no database, no fixtures&lt;br&gt;
published: true&lt;br&gt;
canonical_url: &lt;a href="https://byvibration.com/essays/why-matching-layer-is-physically-blind" rel="noopener noreferrer"&gt;https://byvibration.com/essays/why-matching-layer-is-physically-blind&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  tags: typescript, testing, opensource, webdev
&lt;/h2&gt;

&lt;p&gt;I write the matching engine for a small dating and friendship app. The engine is open-source (github.com/donnowyu/soulmate-core, MIT). The hardest thing about working on it was not the math. It was that for the first three weeks I could not refactor anything because I had no way to know whether my changes had broken the ranking.&lt;/p&gt;

&lt;p&gt;This post is the testing pattern I landed on. 65 deterministic tests. No database. No fixtures loaded from disk. No live embeddings. The whole suite runs in 1.8 seconds and tells me, in plain language, whether any of fifteen ranking invariants just regressed.&lt;/p&gt;

&lt;p&gt;If you maintain anything that takes a query and returns a sorted list (search, recsys, matching, ranking, RAG retrieval), the shape of this should transfer.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem with the obvious test setup
&lt;/h2&gt;

&lt;p&gt;The first version of the test suite was the obvious one. Seed a fake Postgres, insert 200 fake profiles, compute embeddings with the real model, ask the engine to rank them, snapshot the top 20.&lt;/p&gt;

&lt;p&gt;Three problems showed up within a week.&lt;/p&gt;

&lt;p&gt;It was slow. Real embedding calls plus database round trips meant ~40 seconds for the full suite. I stopped running tests before commits.&lt;/p&gt;

&lt;p&gt;It was non-deterministic. The embedding model was probabilistic enough that the same input produced subtly different vectors across runs, which meant the snapshot diff was always noisy. I learned to ignore the diff. Predictably, the day a real regression slipped in I ignored that diff too.&lt;/p&gt;

&lt;p&gt;And the failures, when they came, did not tell me anything. A snapshot would change. I would stare at a list of 20 user IDs and try to remember what each one was supposed to represent. The test had nothing to say about why the new order was wrong, only that it was different.&lt;/p&gt;
&lt;h2&gt;
  
  
  What deterministic tests look like for a matching engine
&lt;/h2&gt;

&lt;p&gt;The flip was to stop testing the system end-to-end and start testing each invariant the ranker is supposed to satisfy, one at a time, with the smallest possible synthetic input.&lt;/p&gt;

&lt;p&gt;Here is one of the tests, verbatim.&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;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;intent overlap dominates lexical similarity&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;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;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;relationship&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;I like long walks&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;friendship&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;I like long walks and hiking&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;viewer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;relationship&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;I like hiking&lt;/span&gt;&lt;span class="dl"&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;ranked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;viewer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;expect&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;relationship&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;There is no database. &lt;code&gt;profile&lt;/code&gt; is a one-line helper that builds a Profile object with sensible defaults. &lt;code&gt;rank&lt;/code&gt; is the real function from the engine. The vectors are synthetic, produced by a tiny deterministic embedder (a stable hash of token bigrams projected into 128 dimensions), not the live model.&lt;/p&gt;

&lt;p&gt;The test reads, in English, as a sentence: "When two candidates have similar text but different intents, the one with matching intent should win." When that test fails, the diagnostic is not "snapshot differs." It is "rank()[0].intent was 'friendship', expected 'relationship'." That tells you exactly which invariant broke.&lt;/p&gt;

&lt;p&gt;There are 65 of these. Each one is roughly four to ten lines. Together they cover the fifteen ranking invariants the matcher is supposed to hold, with several tests per invariant for edge cases (empty fields, very short text, language mismatch, intent set to community, etc.).&lt;/p&gt;

&lt;h2&gt;
  
  
  How to keep them deterministic without faking the math
&lt;/h2&gt;

&lt;p&gt;The trick is the deterministic embedder. It is about thirty lines. It tokenizes the input the same way the real pipeline does, then projects each token into a fixed 128-dimensional vector using a stable hash. Two inputs with identical tokens produce identical vectors. Two inputs that share most tokens produce vectors with a high cosine similarity, just like a real embedder would.&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;function&lt;/span&gt; &lt;span class="nf"&gt;tinyEmbed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&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="nb"&gt;Float32Array&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;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Float32Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;128&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;tok&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;tokenize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&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;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;stableHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tok&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;let&lt;/span&gt; &lt;span class="nx"&gt;i&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&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;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&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="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="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="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&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 is not the right embedder for production. It cannot tell that "happy" and "joyful" are similar. But for testing the ranker, it is exactly right. The ranker should care about intent overlap, language match, profile-completeness, and recency before it cares about the small nuance the real embedder adds. The tests assert those four things hold even with the dumb embedder. If a refactor accidentally makes the engine depend on the embedder's semantic nuance for a basic invariant, the test catches it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why no database
&lt;/h2&gt;

&lt;p&gt;Every test runs against in-memory objects. The engine accepts a viewer profile and a list of candidate profiles, both as plain TypeScript values, and returns a ranked list. The Postgres + pgvector layer that production uses is a separate module that calls into this pure function. The tests do not touch it.&lt;/p&gt;

&lt;p&gt;This is the most expensive design decision in the whole repo. It means the ranker cannot do anything fancy with the database (no clever joins, no SQL-side scoring). Every signal it uses must travel through the function signature. In exchange the tests run in 1.8 seconds and have no flakiness. I have come back to this tradeoff many times. I keep choosing the tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this gets you
&lt;/h2&gt;

&lt;p&gt;Refactor confidence. I rewrote the rerank step three times in two months. Each rewrite, I ran the suite. If the tests passed, the rerank was at least as good as the previous version on every invariant we care about. If a test failed, the message told me exactly which invariant I had broken.&lt;/p&gt;

&lt;p&gt;Honest documentation. The test file is the most useful documentation of how the matcher behaves. New contributors read the tests first. The invariants are not in a wiki that will go stale, they are executable.&lt;/p&gt;

&lt;p&gt;A floor under quality. I cannot make a change that ships a worse ranker by accident. The bar is "all 65 invariants still hold." Below that bar, CI is red.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three things this does not test
&lt;/h2&gt;

&lt;p&gt;Snapshot tests are still useful for catching subtle changes in ranking order on real data. I have a separate test file with two of those, gated on a &lt;code&gt;RUN_INTEGRATION=1&lt;/code&gt; env var, that uses real embeddings and a small fixture set. They are slow and they are noisy. They are not the suite I run on every commit.&lt;/p&gt;

&lt;p&gt;Performance. The 65 tests do not check that ranking is fast. There is a separate benchmark file that runs nightly.&lt;/p&gt;

&lt;p&gt;Calibration. The tests check that the ranker orders correctly. They do not check that the score thresholds match what should be a "good match" for users. That is a product question and the answer comes from user data, not from invariant tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern, generalized
&lt;/h2&gt;

&lt;p&gt;If you maintain a system that takes a query plus a candidate set and returns a sorted list, the pattern is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;List the invariants the ranker should satisfy, in English. "When the user explicitly excludes a language, no candidate of that language should appear in the top N." Write that down.&lt;/li&gt;
&lt;li&gt;For each invariant, build a minimal synthetic input that exercises only it.&lt;/li&gt;
&lt;li&gt;Use a deterministic stand-in for any probabilistic component (embeddings, model calls). Make sure the stand-in is dumb enough that the invariant still has to be satisfied by the ranker, not by accident.&lt;/li&gt;
&lt;li&gt;Skip the persistence layer entirely. Treat the ranker as a pure function.&lt;/li&gt;
&lt;li&gt;Write the assertion in terms of the invariant, not in terms of which specific items came back in which order. "The top result should have intent X" not "the top result should be ID 47."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The reward is a test suite that tells you, in English, what you just broke. After enough refactors that survived it, the suite starts to feel like a colleague who has read every line of the engine and will let you know the moment you do something stupid.&lt;/p&gt;

&lt;p&gt;The full engine, all 65 tests, and the tiny deterministic embedder are at github.com/donnowyu/soulmate-core. If you ever want to see one specific test, &lt;code&gt;tests/rank.invariants.test.ts&lt;/code&gt; is the file. The engine ships on byvibration.com, which is a small relationship and friendship app that genuinely cannot read your photos.---&lt;br&gt;
title: How a photo-blind dating engine actually ranks people (the TypeScript)&lt;br&gt;
published: true&lt;br&gt;
canonical_url: &lt;a href="https://byvibration.com/essays/why-matching-layer-is-physically-blind" rel="noopener noreferrer"&gt;https://byvibration.com/essays/why-matching-layer-is-physically-blind&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  tags: typescript, webdev, postgres, ai
&lt;/h2&gt;

&lt;p&gt;Last post I argued that the matcher in our dating app cannot read photos because the TypeScript types make it impossible. A few people asked the obvious follow-up. If the matcher never sees a face, what does it see, and how does it decide who you should meet this week?&lt;/p&gt;

&lt;p&gt;This post is that. Code samples, vector math, the one heuristic that does most of the work, and the three things we explicitly chose not to do. Repo is at github.com/donnowyu/soulmate-core, MIT.&lt;/p&gt;
&lt;h2&gt;
  
  
  The thing the matcher actually sees
&lt;/h2&gt;

&lt;p&gt;A profile, in the eyes of the ranker, is this:&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;type&lt;/span&gt; &lt;span class="nx"&gt;Profile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;prompts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PromptAnswers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// five short text answers&lt;/span&gt;
  &lt;span class="nl"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VoiceTranscript&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// ~30s recording, kept as text&lt;/span&gt;
  &lt;span class="nl"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Intent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// 'friendship' | 'relationship' | 'community'&lt;/span&gt;
  &lt;span class="nl"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ProfileMeta&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// age band, language, city, locale&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No photo field. No height. No income. No "tags." The strongest input by mass is the prompts plus the voice transcript, which together produce somewhere between 800 and 2,500 tokens of free-form text about how this person actually thinks.&lt;/p&gt;

&lt;p&gt;That text is the matching substrate. Everything downstream is a function of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: turn text into a vector
&lt;/h2&gt;

&lt;p&gt;We embed the concatenated prompts-plus-voice into a fixed-size vector using a text embedding model. The exact provider does not matter much. We use OpenAI's &lt;code&gt;text-embedding-3-small&lt;/code&gt; (1536 dims) because it is cheap, multilingual, and good enough that the rest of the system survives provider churn.&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;// soulmate-core/src/embed.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;embedProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Profile&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Vector&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;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;formatForEmbedding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&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;data&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="nx"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text-embedding-3-small&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&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;data&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="nx"&gt;embedding&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Vector&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;formatForEmbedding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Profile&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prompts&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&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="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&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 vector is what gets stored in Postgres, in a column typed &lt;code&gt;vector(1536)&lt;/code&gt; thanks to &lt;code&gt;pgvector&lt;/code&gt;. The profile row also stores the prompts and the voice transcript for display, but the matcher reads the vector and only the vector. Whatever else lives on the row is not in the function signature, so the compiler cannot accidentally let it leak in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: find candidates with pgvector
&lt;/h2&gt;

&lt;p&gt;Given a viewer with embedded vector &lt;code&gt;v&lt;/code&gt;, the candidate query is a cosine-distance ANN lookup, filtered by intent overlap and a completed-profile gate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;distance&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;profiles&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;completed_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;intents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;target_id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;blocks&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;actor_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;lt;=&amp;gt;&lt;/code&gt; is the pgvector cosine-distance operator. The index is an HNSW on the embedding column so that "100 nearest" runs in milliseconds even at 100k+ profiles. Smaller-distance is more similar, since cosine-distance is &lt;code&gt;1 - cosine-similarity&lt;/code&gt; and the operator returns the distance form.&lt;/p&gt;

&lt;p&gt;Two things to notice. First, the SQL itself reads no photo data. There is no photo table in this join. Second, the candidate set is bounded to 100. The ranker never sees more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: rank the 100 with a more expensive signal
&lt;/h2&gt;

&lt;p&gt;Cosine distance on embeddings is the cheap pass. It is right about taste, off about intent depth. Two people can write similarly and want very different things. So we re-rank the 100 with a second function that does not call an LLM but does look at structured signals the embedding tends to flatten.&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;// soulmate-core/src/rank.ts&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;rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;viewer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Profile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Profile&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;textSim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;viewer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;              &lt;span class="c1"&gt;// 0..1&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;intent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;intentOverlap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;viewer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// 0..1&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;energy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;energyMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;viewer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// 0..1&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cadence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cadenceMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;viewer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// 0..1&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;text&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.55&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="nx"&gt;intent&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="nx"&gt;energy&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="nx"&gt;cadence&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.08&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;&lt;code&gt;textSim&lt;/code&gt; is the cosine similarity reconstructed from the distance returned by Postgres. &lt;code&gt;intentOverlap&lt;/code&gt; weighs whether both sides want the same kind of connection (friendship, relationship, community), and how strongly. &lt;code&gt;energyMatch&lt;/code&gt; and &lt;code&gt;cadenceMatch&lt;/code&gt; are small heuristics derived from how much text the person wrote and how fast they answer messages historically. They mostly catch the case where two people are similar on substance but operate on incompatible rhythms.&lt;/p&gt;

&lt;p&gt;The weights are not fitted. They are intuitions we did not have data to fit yet, and we kept them in code so any future change is a real diff and not a parameter twiddle that nobody notices. When we have enough signal to fit them, we will, and that PR will be reviewable in one page.&lt;/p&gt;

&lt;p&gt;The function returns one float. We pick the top 5 above a 0.45 threshold for the weekly batch. If fewer than 5 cross the threshold, we send fewer. We do not pad.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we explicitly did not do
&lt;/h2&gt;

&lt;p&gt;Three things kept coming up in design review and we kept choosing not to.&lt;/p&gt;

&lt;p&gt;We did not build a feed. There is no infinite-scroll candidate stream in this product. The weekly batch is the whole surface. The argument for a feed is engagement; we are intentionally trading engagement for a different shape of behavior, the one where the user opens the app rarely and deliberately.&lt;/p&gt;

&lt;p&gt;We did not let the matcher see photos, not even as a tiebreaker. We considered the version where photos enter at rank time with a small weight, and rejected it for the obvious type-system reason and the less obvious behavioral one: as soon as the matcher can see faces, the production data collection of "what humans clicked on" starts encoding face preference into the ranker even if no explicit feature does. The cleanest defense is to make the photo bytes literally unreachable from the function. The compiler is the policy.&lt;/p&gt;

&lt;p&gt;We did not put an LLM in the ranker. The temptation is real, especially since we are already embedding text. We resisted because an LLM in the loop makes the function opaque in a way that the four-feature linear combination is not. If a match is wrong, we can read the four numbers. We cannot read an LLM the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters outside dating
&lt;/h2&gt;

&lt;p&gt;The pattern, embedding-plus-pgvector-plus-small-linear-rerank, is good for any product where the primary signal is "how this user thinks" rather than "what this user clicked on." Documentation search, similar-issue triage, mentor matching, study-group formation. The dating context is just the one where the cost of being wrong is most visible to the user.&lt;/p&gt;

&lt;p&gt;If you want to read the full implementation, it is at github.com/donnowyu/soulmate-core, all of it under MIT. The vector math is in &lt;code&gt;src/rank.ts&lt;/code&gt; and &lt;code&gt;src/embed.ts&lt;/code&gt;; the SQL is in &lt;code&gt;db/migrations/&lt;/code&gt;. Tests cover the rank function and the edge cases of empty profiles, missing voices, and intent mismatch.&lt;/p&gt;

&lt;p&gt;The product that wraps this engine is byvibration.com. It is the same idea taken all the way to a working app: you write, the engine reads how you think, you meet by mind not by face.&lt;/p&gt;

&lt;p&gt;I work on byvibration. The framework above stands on its own; the product is one way to live inside it.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>testing</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I shipped 19 SEO essays in 12 days from a single Next.js page file</title>
      <dc:creator>gyani</dc:creator>
      <pubDate>Sat, 16 May 2026 07:22:48 +0000</pubDate>
      <link>https://dev.to/ggyanie/i-shipped-19-seo-essays-in-12-days-from-a-single-nextjs-page-file-5615</link>
      <guid>https://dev.to/ggyanie/i-shipped-19-seo-essays-in-12-days-from-a-single-nextjs-page-file-5615</guid>
      <description>&lt;p&gt;I have been quietly running an experiment for the last twelve days. I wanted to know how minimal the publishing pipeline for a real SEO essay corpus can be if I gave up every CMS, every markdown loader, and every static-site generator that pretended to be lightweight but turned out to require its own ecosystem.&lt;/p&gt;

&lt;p&gt;The answer ended up being one Next.js page file, one slug allowlist, one sitemap function, and a postscript script that probes the live URL after deploy. Nineteen essays are live as I write this, all ranked individually in &lt;code&gt;sitemap.xml&lt;/code&gt;, all internally linked, all with stable URLs and zero rebuild surprises. The whole pipeline is small enough that a human in a hurry can read it in five minutes.&lt;/p&gt;

&lt;p&gt;This post is the file. Not a tutorial about the file. The actual structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape
&lt;/h2&gt;

&lt;p&gt;The route lives at &lt;code&gt;apps/web/app/essays/[slug]/page.tsx&lt;/code&gt;. Everything an essay needs is in two arrays in that file plus one small allowlist file next to it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/web/app/essays/[slug]/page.tsx&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;notFound&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/navigation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ESSAY_SLUGS&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/essay-slugs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dynamic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;force-static&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dynamicParams&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="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Essay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;ESSAY_SLUGS&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;title&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;publishedAt&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;body&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="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ESSAYS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Essay&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;why-matching-layer-is-physically-blind&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Why the matching layer is physically blind, on purpose&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;publishedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-05-08&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`
      ... essay prose here, plain markdown-ish strings ...
    `&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// ... 18 more&lt;/span&gt;
&lt;span class="p"&gt;];&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;generateStaticParams&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;ESSAY_SLUGS&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;slug&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;slug&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;EssayPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slug&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="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;essay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ESSAYS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&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="o"&gt;=&amp;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;slug&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;essay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;notFound&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Article&lt;/span&gt; &lt;span class="na"&gt;essay&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;essay&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ESSAY_SLUGS&lt;/code&gt; is a &lt;code&gt;const&lt;/code&gt; tuple in its own file so the type system catches typos at compile time. Slugs in the renderer that are not in the allowlist will not type-check; slugs in the allowlist with no renderer entry will hit &lt;code&gt;notFound()&lt;/code&gt; and return a real &lt;code&gt;404&lt;/code&gt;.&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;// apps/web/lib/essay-slugs.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ESSAY_SLUGS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;why-matching-layer-is-physically-blind&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;letters-mode-is-mercy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;why-dating-apps-feel-exhausting&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ... 16 more&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the single source of truth for the corpus. Sitemap reads it. Index page reads it. Renderer reads it. Three callers, one list. When a new essay ships, one line in this file and one entry in the renderer is the whole diff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sitemap, index, internal linking
&lt;/h2&gt;

&lt;p&gt;Because &lt;code&gt;ESSAY_SLUGS&lt;/code&gt; is a typed tuple, the sitemap generator is six lines.&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;// apps/web/app/sitemap.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MetadataRoute&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ESSAY_SLUGS&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/essay-slugs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sitemap&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;MetadataRoute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Sitemap&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;ESSAY_SLUGS&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;slug&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="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`https://byvibration.com/essays/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;lastModified&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&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 &lt;code&gt;/essays&lt;/code&gt; index page does the same lookup and renders a card per essay, ordered by &lt;code&gt;publishedAt&lt;/code&gt; descending. Adding a new essay automatically promotes it to the top of the index and into the sitemap on the next deploy. There is nothing else to remember.&lt;/p&gt;

&lt;p&gt;Internal linking is a function call inside the prose. Each essay body has a small &lt;code&gt;Related&lt;/code&gt; block at the bottom that pulls related slugs by cluster tag (introvert cluster, friendship cluster, etc.). The cluster mapping is another tiny &lt;code&gt;const&lt;/code&gt; next to the slug list. Total moving parts so far: three files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "is it actually live" probe
&lt;/h2&gt;

&lt;p&gt;Vercel deploys are usually fast, but there is one failure mode that bit me hard. A page can return &lt;code&gt;HTTP 200&lt;/code&gt; while serving the home shell when something upstream of the renderer crashes silently. The status code lies.&lt;/p&gt;

&lt;p&gt;To catch this, the post-deploy probe asserts three things: the page returns &lt;code&gt;200&lt;/code&gt;, the rendered &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; contains a stem of the slug, and the slug is present in the live &lt;code&gt;sitemap.xml&lt;/code&gt;. If any of those fail, the deploy is treated as not-live, even on &lt;code&gt;200&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# superbot/util/essay_liveness.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_live&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://byvibration.com/essays/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&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="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;title&amp;gt;([^&amp;lt;]+)&amp;lt;/title&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;stem&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;stem&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stem&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;3&lt;/span&gt;
    &lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;sitemap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://byvibration.com/sitemap.xml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/essays/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sitemap&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the single most-useful seven-minute piece of code in the pipeline. It caught a soft-&lt;code&gt;404&lt;/code&gt; for me on essay number five before I noticed the slug was being silently rewritten by middleware. The fix took ten minutes; without the probe it would have taken a week of confusion about why search was not seeing the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did not build
&lt;/h2&gt;

&lt;p&gt;A markdown loader. A frontmatter parser. An MDX pipeline. A CMS adapter. A headless preview environment. A content directory. A draft state machine. A separate build pipeline for content vs. application code.&lt;/p&gt;

&lt;p&gt;Every one of those was suggested by some part of my brain along the way. None of them earned their place. The reason is honest: the corpus is small, the cost of typing prose inline is trivial, and the type checker is the only quality gate that actually catches the bugs that ship in production. The simplest model that works is the model.&lt;/p&gt;

&lt;p&gt;The day I have one hundred essays I will probably move to a markdown directory. Until then, the file fits on one screen of any normal editor, the array is sorted by date, and I can grep my own corpus instantly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this looks like in practice
&lt;/h2&gt;

&lt;p&gt;The cadence has been roughly one essay every fifteen hours, written in plain prose, ported into the array, slug added to the allowlist, pushed. Vercel deploys. The probe runs. The sitemap updates. Google indexes it within forty-eight hours. The internal links to the rest of the corpus stay correct because they are computed, not hand-maintained.&lt;/p&gt;

&lt;p&gt;The unit of friction per new essay is "write the essay." Everything downstream of that is one diff against one file.&lt;/p&gt;

&lt;p&gt;If you have an essay practice and you are intimidated by the pipeline question, this is a version of "just ship it" that has an answer. One file. One allowlist. One probe. Nineteen essays in twelve days from that pattern, with the type checker as your friend.&lt;/p&gt;

&lt;p&gt;I would skip every CMS until you actually need one.&lt;/p&gt;




&lt;p&gt;I work on Byvibration, where the corpus this file feeds lives. The essays index is at byvibration.com/essays.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>productivity</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A dating algorithm that physically cannot read photos (and why I wrote it that way)</title>
      <dc:creator>gyani</dc:creator>
      <pubDate>Sat, 16 May 2026 00:44:18 +0000</pubDate>
      <link>https://dev.to/ggyanie/a-dating-algorithm-that-physically-cannot-read-photos-and-why-i-wrote-it-that-way-268d</link>
      <guid>https://dev.to/ggyanie/a-dating-algorithm-that-physically-cannot-read-photos-and-why-i-wrote-it-that-way-268d</guid>
      <description></description>
    </item>
    <item>
      <title>A dating algorithm that physically cannot read photos (and why I wrote it that way)</title>
      <dc:creator>gyani</dc:creator>
      <pubDate>Fri, 15 May 2026 15:13:14 +0000</pubDate>
      <link>https://dev.to/ggyanie/a-dating-algorithm-that-physically-cannot-read-photos-and-why-i-wrote-it-that-way-5gb</link>
      <guid>https://dev.to/ggyanie/a-dating-algorithm-that-physically-cannot-read-photos-and-why-i-wrote-it-that-way-5gb</guid>
      <description>&lt;p&gt;I have been writing a connection app for a year. Last week I open-sourced the matching engine, and the only design choice I want to walk through is the one that took the longest to talk myself into: the matcher does not have access to photos. Not "it ignores them." Not "it deprioritizes them." It cannot see them. The TypeScript build fails if you try.&lt;/p&gt;

&lt;p&gt;If you only want the punchline, here it is.&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;// soulmate-core/src/rank.ts&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;rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;viewer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Profile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Profile&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="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Profile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;prompts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PromptAnswers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// five short text answers&lt;/span&gt;
  &lt;span class="nl"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VoiceTranscript&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// ~30 sec, kept as text&lt;/span&gt;
  &lt;span class="nl"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Intent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;               &lt;span class="c1"&gt;// friendship | relationship | community&lt;/span&gt;
  &lt;span class="nl"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ProfileMeta&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// age band, city, language, etc.&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="c1"&gt;// no photo field. anywhere on this type.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The image bytes live in a different table, behind a different read path, behind a &lt;code&gt;mutualVibe&lt;/code&gt; boolean. The function above has no reference to that table and no way to obtain one through normal app wiring. The constraint is enforced by the compiler.&lt;/p&gt;

&lt;p&gt;The repo is at &lt;code&gt;github.com/donnowyu/soulmate-core&lt;/code&gt; if you want to read along.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why type-level, not flag-level
&lt;/h2&gt;

&lt;p&gt;The natural shape of this is a feature flag. &lt;code&gt;if (allow_photo_in_ranking) { ... }&lt;/code&gt;. Several products built on this shape. I think it is the wrong shape. Three reasons.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Flags get flipped by people who weren't in the room when the principle was set.&lt;/strong&gt; A future engineer, looking at the engagement dashboard on a tired evening, will propose a "secondary signal" A/B test. They will be right that the metric will move. They will be wrong that what is being measured is what we said we cared about. A flag does not survive that conversation. A type signature does.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The constraint should live in the artifact, not the documentation.&lt;/strong&gt; A README that says "do not use photos in ranking" is a memo. A type that has no photo field is a build error. Banks do not enforce referential integrity with memos.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;It is honest in a way I can verify in public.&lt;/strong&gt; The repo is open. You can look at the entry-point type and convince yourself in 60 seconds. You do not have to take my word for anything.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The cost of doing it this way
&lt;/h2&gt;

&lt;p&gt;I will not pretend this was free.&lt;/p&gt;

&lt;p&gt;The most expensive part was the data model. I had to design the schema so that the photo entity has its own service, its own access control, its own read path. The image upload pipeline never returns to the matching service. The "show me a face" step is a separate request, gated server-side on the existence of a &lt;code&gt;mutualVibe&lt;/code&gt; row keyed by both user IDs. That is not a refactor you do in an afternoon.&lt;/p&gt;

&lt;p&gt;The second cost was deciding what &lt;code&gt;Profile&lt;/code&gt; should contain so that ranking still works. I tried a lot of things. The current shape (five prompts plus a transcribed voice clip plus intent metadata) is the smallest set I found that produces matches I can defend on inspection. Most of a year was spent reducing it to that.&lt;/p&gt;

&lt;p&gt;The third cost is a soft one. There is a class of user who, on the existing apps, sorts mostly by face. They will look at this product and bounce. That is fine. They were not the users I was trying to find.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick of the embedding
&lt;/h2&gt;

&lt;p&gt;The text answers and the voice transcript get concatenated into a single document per user. That document is embedded into a 1536-dim vector. Ranking is cosine similarity over those vectors, with two soft rerankers (ideology distance, shared-passion overlap) breaking ties.&lt;/p&gt;

&lt;p&gt;This is not exotic. The trick is not in the math. The trick is in the input. By construction, the model has never seen a pixel. By construction, the model has no learned latent dimension that correlates with attractiveness, because nothing in the training distribution ever encoded one. The rerank loop is small enough to read.&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;// rerank pseudocode&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;baseline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cosine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;viewerEmb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidateEmb&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;ideologyPenalty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;distance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;viewer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ideology&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ideology&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;passionBoost&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;jaccard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;viewer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;passions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;passions&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;baseline&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;ideologyPenalty&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;passionBoost&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can argue with the coefficients. I have. The coefficients are not the point of the post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I am writing this on Dev.to
&lt;/h2&gt;

&lt;p&gt;Because the type-system argument is the part of the project that is interesting to people who write code for a living, and because most of the press around "no photo dating apps" handles the question at the marketing layer, where it is much less interesting. The interesting question is whether the constraint is structural, and structural constraints are something a dev audience can read in source. I wanted that audience to be able to verify the claim without me in the room.&lt;/p&gt;

&lt;p&gt;If you want the long-form essay version of this argument, it is on the product site at &lt;code&gt;byvibration.com/essays/why-matching-layer-is-physically-blind&lt;/code&gt;. If you want the code, the repo link is at the top. If you want to push back on any of the choices, the comments are open and I will be in them.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I work on byvibration. The matching engine is open source. I am writing about it here because I think the type-signature framing is a transferable idea: constraints you want to honor across a long time should be expressed in the artifact, not the team's memory.&lt;/em&gt;---&lt;br&gt;
title: A dating algorithm that physically cannot read photos (and why I wrote it that way)&lt;br&gt;
published: false&lt;br&gt;
canonical_url: &lt;a href="https://byvibration.com/essays/why-matching-layer-is-physically-blind" rel="noopener noreferrer"&gt;https://byvibration.com/essays/why-matching-layer-is-physically-blind&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  tags: typescript, webdev, discuss, architecture
&lt;/h2&gt;

&lt;p&gt;I have been writing a connection app for a year. Last week I open-sourced the matching engine, and the only design choice I want to walk through is the one that took the longest to talk myself into: the matcher does not have access to photos. Not "it ignores them." Not "it deprioritizes them." It cannot see them. The TypeScript build fails if you try.&lt;/p&gt;

&lt;p&gt;If you only want the punchline, here it is.&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;// soulmate-core/src/rank.ts&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;rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;viewer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Profile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Profile&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="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Profile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;prompts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PromptAnswers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// five short text answers&lt;/span&gt;
  &lt;span class="nl"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VoiceTranscript&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// ~30 sec, kept as text&lt;/span&gt;
  &lt;span class="nl"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Intent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;               &lt;span class="c1"&gt;// friendship | relationship | community&lt;/span&gt;
  &lt;span class="nl"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ProfileMeta&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// age band, city, language, etc.&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="c1"&gt;// no photo field. anywhere on this type.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The image bytes live in a different table, behind a different read path, behind a &lt;code&gt;mutualVibe&lt;/code&gt; boolean. The function above has no reference to that table and no way to obtain one through normal app wiring. The constraint is enforced by the compiler.&lt;/p&gt;

&lt;p&gt;The repo is at &lt;code&gt;github.com/donnowyu/soulmate-core&lt;/code&gt; if you want to read along.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why type-level, not flag-level
&lt;/h2&gt;

&lt;p&gt;The natural shape of this is a feature flag. &lt;code&gt;if (allow_photo_in_ranking) { ... }&lt;/code&gt;. Several products built on this shape. I think it is the wrong shape. Three reasons.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Flags get flipped by people who weren't in the room when the principle was set.&lt;/strong&gt; A future engineer, looking at the engagement dashboard on a tired evening, will propose a "secondary signal" A/B test. They will be right that the metric will move. They will be wrong that what is being measured is what we said we cared about. A flag does not survive that conversation. A type signature does.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The constraint should live in the artifact, not the documentation.&lt;/strong&gt; A README that says "do not use photos in ranking" is a memo. A type that has no photo field is a build error. Banks do not enforce referential integrity with memos.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;It is honest in a way I can verify in public.&lt;/strong&gt; The repo is open. You can look at the entry-point type and convince yourself in 60 seconds. You do not have to take my word for anything.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The cost of doing it this way
&lt;/h2&gt;

&lt;p&gt;I will not pretend this was free.&lt;/p&gt;

&lt;p&gt;The most expensive part was the data model. I had to design the schema so that the photo entity has its own service, its own access control, its own read path. The image upload pipeline never returns to the matching service. The "show me a face" step is a separate request, gated server-side on the existence of a &lt;code&gt;mutualVibe&lt;/code&gt; row keyed by both user IDs. That is not a refactor you do in an afternoon.&lt;/p&gt;

&lt;p&gt;The second cost was deciding what &lt;code&gt;Profile&lt;/code&gt; should contain so that ranking still works. I tried a lot of things. The current shape (five prompts plus a transcribed voice clip plus intent metadata) is the smallest set I found that produces matches I can defend on inspection. Most of a year was spent reducing it to that.&lt;/p&gt;

&lt;p&gt;The third cost is a soft one. There is a class of user who, on the existing apps, sorts mostly by face. They will look at this product and bounce. That is fine. They were not the users I was trying to find.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick of the embedding
&lt;/h2&gt;

&lt;p&gt;The text answers and the voice transcript get concatenated into a single document per user. That document is embedded into a 1536-dim vector. Ranking is cosine similarity over those vectors, with two soft rerankers (ideology distance, shared-passion overlap) breaking ties.&lt;/p&gt;

&lt;p&gt;This is not exotic. The trick is not in the math. The trick is in the input. By construction, the model has never seen a pixel. By construction, the model has no learned latent dimension that correlates with attractiveness, because nothing in the training distribution ever encoded one. The rerank loop is small enough to read.&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;// rerank pseudocode&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;baseline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cosine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;viewerEmb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidateEmb&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;ideologyPenalty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;distance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;viewer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ideology&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ideology&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;passionBoost&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;jaccard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;viewer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;passions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;passions&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;baseline&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;ideologyPenalty&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;passionBoost&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can argue with the coefficients. I have. The coefficients are not the point of the post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I am writing this on Dev.to
&lt;/h2&gt;

&lt;p&gt;Because the type-system argument is the part of the project that is interesting to people who write code for a living, and because most of the press around "no photo dating apps" handles the question at the marketing layer, where it is much less interesting. The interesting question is whether the constraint is structural, and structural constraints are something a dev audience can read in source. I wanted that audience to be able to verify the claim without me in the room.&lt;/p&gt;

&lt;p&gt;If you want the long-form essay version of this argument, it is on the product site at &lt;code&gt;byvibration.com/essays/why-matching-layer-is-physically-blind&lt;/code&gt;. If you want the code, the repo link is at the top. If you want to push back on any of the choices, the comments are open and I will be in them.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I work on byvibration. The matching engine is open source. I am writing about it here because I think the type-signature framing is a transferable idea: constraints you want to honor across a long time should be expressed in the artifact, not the team's memory.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>webdev</category>
      <category>discuss</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
