The matching algorithm

A complete technical reference for every formula, component, and design decision behind Affinity Atlas compatibility scoring. Nothing is hidden. If you want to understand exactly how two people are matched - or why we rejected other approaches - this is the document.

By 0xBrewEntropy - Last updated March 2026

1. The core formula

Every compatibility score in Affinity Atlas is computed from a single formula. No black box, no opaque neural network, no engagement-optimised ranking. One equation, fully deterministic, fully explainable.

Core compatibility formula
Compatibility = Σ(Affinity × NicheWeight × SignalWeight × CategoryMultiplier) / Σ(SignalWeight × CategoryMultiplier)

Normalised to a 0-100 score. The summation runs only over data sources where both users have data.

The numerator sums weighted affinity across every shared data point - every artist, game, language, beer style, director, author, Q&A answer, and more - between two users. The denominator normalises this sum so the result is a percentage of the maximum possible score given the data available.

Four components multiply together per data point. Each encodes a different dimension of compatibility. The following sections define each one precisely.

2. Component deep-dive

2.1 Affinity

Affinity replaces naive Boolean overlap ("do both users have this item?") with a continuous 0-1 score that accounts for how much each user cares about an item.

Affinity
Affinity = Presence × EngagementFactor × SentimentFactor
Presence
Binary: 1 if both users share the item, 0 otherwise. This is always available regardless of data richness. If an item is not shared, Affinity is 0 and no further computation occurs.
EngagementFactor
max(0.05, sqrt(logNorm(engA, cap) × logNorm(engB, cap)))
The geometric mean of both users' normalised engagement depth. Raw values (minutes listened, hours played, repos per language, check-ins per style, books read per author) are normalised to 0-1 using log-scale normalisation: logNorm(v, cap) = min(1, log(1+v) / log(1+cap)) where cap is a source-specific ceiling. The geometric mean ensures a shared item only scores highly when both users are meaningfully engaged. Defaults to 1.0 when engagement data is unavailable (Tier 2/3 fallback).
SentimentFactor
min(sentA, sentB)
The minimum of both users' normalised sentiment (ratings normalised to 0-1, or Boolean saved/liked mapped to 1.0 positive / 0.3 negative). If either user has a negative signal (low rating, skip, very low playtime), the factor drops to 0.2-0.3, filtering out false positives. Defaults to 1.0 when rating data is unavailable.

Data tiers and graceful degradation

Not every source provides the same data richness. Affinity handles three tiers:

  • Tier 1 - Rich (Presence + Engagement + Sentiment): i.e. Spotify (minutes + saved status), Untappd (check-ins + rating), Goodreads (books read + star rating). All three dimensions active.
  • Tier 2 - Moderate (Presence + Engagement OR Sentiment): i.e. Steam (hours played + implicit sentiment from low playtime), GitHub (repos per language, no rating). The missing dimension defaults to 1.0.
  • Tier 3 - Basic (Presence only): i.e. self-reported interests, platform follows. Affinity = Presence (1 or 0). NicheWeight still applies based on platform-level frequency data.

Why this matters: A naive algorithm treats "both users have Igorrr" as a binary match. But User A listened for 1,200 minutes across 6 months; User B heard one track for 90 seconds and never returned. Affinity produces 0.86 for the deep mutual fan and 0.11 for the false positive - an 8× difference that a Boolean model would miss entirely.

2.2 NicheWeight

NicheWeight rewards rare overlaps. Two users sharing an artist with a popularity of 12 (out of 100) is a far stronger identity signal than both knowing a chart-topping artist at popularity 90.

NicheWeight
NicheWeight = max(0.05, 1 - popularity / 100)
popularity
A 0-100 scale reflecting how common an item is. For Spotify, this comes directly from the API. For other sources, it is derived from: global ownership percentile (Steam), topic/language frequency (GitHub), style rarity (Untappd), director/genre viewership (Letterboxd), genre/author readership (Goodreads), or platform-level frequency within the Affinity Atlas user base.

Examples: popularity 90 → NicheWeight 0.10 (barely distinctive). Popularity 10 → NicheWeight 0.90 (highly distinctive). Popularity 50 → NicheWeight 0.50 (moderate signal). The floor at 0.05 ensures even the most popular items contribute a small amount.

NicheWeight applies identically to shared likes and shared dislikes. Two users who both avoid a moderately popular language (PHP, popularity ~55 → NicheWeight 0.45) share a more distinctive signal than two users who both dislike a very popular one (popularity 85 → NicheWeight 0.15).

2.3 SignalWeight

SignalWeight controls the relative importance of each data source. Some sources produce richer, more predictive signals than others.

SignalWeight (defaults)
Spotify: 1.0 | Steam: 1.0 | Q&A: 1.2 | Untappd: 0.9 | Letterboxd: 0.85 | Goodreads: 0.85 | GitHub: 0.8 | Events: 0.7 | Strava: 0.6
Rationale
Q&A scores highest because it captures values and preferences directly. Music and gaming data are rich in engagement depth and cover a wide signal range. Film, book, and drink data carry strong cultural identity signals but with fewer data points per user. Code repositories are meaningful but narrower in dating context. Fitness and event data provide lifestyle signals with less taste granularity.
User-configurable
Users can adjust these weights via "Match me on" controls, boosting categories they care about and suppressing ones they want excluded from dating decisions.

2.4 CategoryMultiplier

CategoryMultiplier reflects how much each user cares about this interest category. If both users rate Music as very important, that category's contribution is boosted. If one cares deeply about Beer and the other does not, it is pulled down.

CategoryMultiplier
CategoryMultiplier = clamp(sqrt(wA × wB) × 1.5, 0.05, 1.5)
wA, wB
Each user's importance weight for this category, on a 0-1 scale (derived from a 0-100 user input with presets: Low = 0.20, Somewhat = 0.45, Very = 0.75, Dealbreaker = 0.95).
Geometric mean
sqrt(wA × wB) ensures mutual importance is rewarded disproportionately. If both users set Music to 0.75 (“Very”), the product is 0.5625 and the sqrt is 0.75, scaled to 1.125 - a boost. If one sets 0.75 and the other 0.20, the product is 0.15, sqrt is 0.387, scaled to 0.58 - a reduction.
Clamping
The floor at 0.05 prevents any category from being fully zeroed out (a user who sets "Low" still has their data considered, just minimally). The ceiling at 1.5 caps the boost to prevent a single mutual-dealbreaker category from dominating the entire score.
Scope
CategoryMultiplier only acts across categories. Within a category, two Spotify libraries are compared identically regardless of the weight. The multiplier scales the category's contribution to the overall score, not the internal comparison logic.

3. Worked example

Two users, both with Spotify connected. User A and User B both have the artist Igorrr in their listening data.

Deep mutual fans

Spotify popularity: 18 → NicheWeight = 0.82

User A: 1,200 min listened, saved ✓

User B: 860 min listened, saved ✓

EngagementFactor = sqrt(0.89 × 0.83) = 0.86

SentimentFactor = min(1.0, 1.0) = 1.0

Affinity = 0.86 × 1.0 = 0.86

Contribution = 0.86 × 0.82 × 1.0 = 0.71

vs
False positive

Spotify popularity: 18 → NicheWeight = 0.82

User A: 1,200 min listened, saved ✓

User C: 3 min listened, not saved ✗

EngagementFactor = sqrt(0.89 × 0.14) = 0.35

SentimentFactor = min(1.0, 0.3) = 0.3

Affinity = 0.35 × 0.3 = 0.11

Contribution = 0.11 × 0.82 × 1.0 = 0.09

Same artist, same NicheWeight - but the deep mutual fan pair scores 7.9× higher than the false positive. A Boolean model would score both identically. This is why Affinity exists.

4. The normalisation problem

The most algorithmically interesting challenge in a multi-source matching system is not the scoring itself - it is the denominator. When two users have different numbers of connected integrations, how do you divide?

Consider: User A connects only Spotify. User B connects Spotify, Steam, GitHub, Untappd, and Goodreads. They have a perfect alignment on Spotify. What should the compatibility score be?

The answer depends entirely on what you put in the denominator. Every approach to this problem has trade-offs, and many of the obvious solutions create a fundamental flaw: the two users see different scores for the same pair. This is the bidirectionality problem.

Bidirectionality requirement: Both users in a pair must always see the same compatibility score. If User A sees 82% and User B sees 47% for the same match, the system's credibility collapses. Every scoring approach must produce a single, symmetric number.

The following sections document every approach we evaluated - both the bidirectional methods (one of which was selected) and the non-bidirectional approaches (all of which were rejected on principle before considering their other trade-offs).

5. Bidirectional methods considered

Six symmetric approaches were evaluated. All produce the same score for both users in a pair.

M1 Shared-source-only normalisation Selected
Σ(Affinity × NicheWeight × SignalWeight × CategoryMultiplier) / Σ(SignalWeight × CategoryMultiplier)
where both sums run only over sources both users have connected

The denominator includes only sources where both users have data. If the only shared source is Spotify, the entire score comes from Spotify.

  • Perfectly bidirectional - both users always see the same score
  • Zero penalty for connecting fewer platforms
  • Simple to compute and simple to explain
  • Score reflects actual data quality, not data quantity
  • Scores can be high on thin data (a strong Spotify-only match shows 85% even though no other signals exist)
  • No inherent incentive to connect more integrations (the score itself does not change)

The thin-data concern is addressed through transparency: a category breakdown shows which sources contributed, and explore prompts encourage connecting more platforms. The algorithm itself stays pure.

M2 Score + confidence badge Not selected
Score = M1 formula (shared-source only)
Confidence = sharedSourceCount / max(sourceCountA, sourceCountB)

Shared-source score with a secondary confidence indicator displayed alongside it (i.e. "82% match based on 1 of 5 categories").

  • Bidirectional
  • Completely honest about data coverage
  • Score itself is never artificially reduced
  • Two numbers to parse - users must understand both percentage and confidence
  • Users may ignore confidence and fixate on the headline number
  • Sorting and ranking become ambiguous: sort by score, by confidence, or by some composite?

Why not selected: Adds cognitive load without improving the score itself. The transparency benefits are better delivered through category breakdowns (which are more informative than a single confidence fraction).

M3 Coverage discount Not selected
FinalScore = SharedSourceScore × CoverageFactor
CoverageFactor = max(floor, (sharedWeight / maxWeight) ^ dampening)
i.e. floor = 0.5, dampening = 0.3

Shared-source score multiplied by a dampened coverage factor. With dampening = 0.3 and floor = 0.5: 1 of 5 categories shared → CoverageFactor ≈ 0.62. 3 of 5 → ≈ 0.85. 5 of 5 → 1.0.

  • Bidirectional
  • Single number - no dual-display complexity
  • Rewards more data without destroying sparse users
  • The dampening curve and floor are arbitrary - there is no principled way to choose 0.3 or 0.5
  • A sparse-but-perfect pair can never reach 100%, which feels dishonest
  • Hard to explain: "your score is 82% but we multiplied it by 0.62 because you only share one source"

Why not selected: Introduces an arbitrary tuning parameter that cannot be justified from first principles. Penalises sparse users in a way that contradicts the platform's accessibility goals.

M4 Bayesian prior (assume average) Not selected
For each source only User B has:
imputedScore = populationMeanAffinity × NicheWeight_mean × SignalWeight

FinalScore = (realSharedScore + ΣimputedScores) / (sharedDenom + imputedDenom)

For sources only one user has connected, impute a population-average Affinity (i.e. 0.35) for the missing user. The denominator grows to include all sources either user has.

  • Bidirectional (both users see the same imputed score)
  • Missing sources contribute a neutral baseline rather than nothing
  • Users with more data get differentiation; users with less get benefit of the doubt
  • Invents data - the system fabricates scores for sources a user has not connected
  • The population mean is a guess that varies by source, demographic, and time
  • Hides potentially bad matches on missing sources behind a neutral assumption
  • Extremely hard to explain to users: "we assumed your gaming taste is average because you did not connect Steam"

Why not selected: Violates the transparency principle. Users cannot trust a score that is partly fabricated. The population mean requires ongoing calibration and can introduce demographic bias.

M5 Additive coverage bonus Not selected
FinalScore = min(100, SharedSourceScore + CoverageBonus)
CoverageBonus = cap × (1 - e^(-k × numSharedSources))
i.e. cap = 10, k = 0.5

Base score from shared sources, plus a small additive bonus for data richness. With cap = 10 and k = 0.5: 1 shared source → +3.9 points. 3 shared → +7.8. 5 shared → +9.2.

  • Bidirectional
  • Base score is fair and unpenalised
  • More data gives a gentle nudge upward
  • The bonus cap and curve are arbitrary
  • Relatively small impact (3-10 points) may not meaningfully affect ranking
  • Mixes two different concepts (compatibility quality and data quantity) into one number

Why not selected: The bonus is too small to matter for ranking but large enough to confuse the score's meaning. A 78% on one source showing as 82% obscures what the number actually represents.

M6 Two-tier display (Known + Explore) Not selected
KnownScore = M1 formula (shared-source only)
ExploreList = categories where only one user has data
Display: "78% compatible on Music. Connect Gaming to see if you align there too."

Score computed only on shared sources, with a separate discovery prompt listing unscored categories.

  • Bidirectional
  • Completely transparent - the score is never inflated or deflated
  • Encourages organic integration growth through curiosity
  • Most complex UX of all approaches
  • Requires rethinking how Discover cards rank and display
  • Users wanting a quick-glance number for swiping may find it friction-heavy

Why not selected as the primary method: Too much UX complexity for a primary scoring display. However, elements of this approach (the explore prompt and category breakdown) were incorporated into Method 1, giving the best of both worlds: a clean single score with optional drill-down.

6. Rejected non-bidirectional approaches

Before arriving at the six bidirectional methods above, several normalisation strategies were evaluated and rejected because they produce different scores for each user in the same pair. These are documented here both for completeness and to illustrate why bidirectionality is a non-negotiable constraint.

Note: Every method in this section was rejected on principle. Even those with otherwise appealing properties were discarded because asymmetric scores undermine user trust and create impossible UX problems (whose score do you show on the match card?).

R1 Per-user normalisation Non-bidirectional
Score_A = Σ(Affinity × NicheWeight × SignalWeight × CategoryMultiplier) / Σ(SignalWeight_A × CategoryMultiplier_A)

Score_B = Σ(Affinity × NicheWeight × SignalWeight × CategoryMultiplier) / Σ(SignalWeight_B × CategoryMultiplier_B)

where the denominator for each user includes ALL sources that user has connected

Each user's score is normalised against their own connected sources. The numerator is the same (shared data), but the denominator differs.

Σ(SignalWeight_A × CategoryMultiplier_A)
Sum of weights across all sources User A has connected (not just shared ones). If A has only Spotify: denominator = 1.0. If B has Spotify + Steam + GitHub + Untappd + Goodreads: denominator ≈ 4.55.
The asymmetry problem:

With a perfect Spotify alignment (numerator contribution ≈ 0.85 for both):

  • User A sees: 0.85 / 1.0 = 85%
  • User B sees: 0.85 / 4.55 = 19%

User A thinks this is a great match. User B thinks it is a poor one. The same pair, the same data, two completely different conclusions. If User A messages User B excitedly about their "85% compatibility", User B's interface shows 19%. Trust is destroyed.

Additionally, this approach penalises data-rich users. A user who connects five sources will see lower scores across the board because their denominator is always large, regardless of the match quality. This creates a perverse incentive to connect fewer integrations, which is the exact opposite of the platform's goal.

R2 Union-based normalisation Non-bidirectional
Score = Σ(Affinity × NicheWeight × SignalWeight × CategoryMultiplier) / Σ(SignalWeight_union × CategoryMultiplier_union)

where the denominator includes every source EITHER user has connected (the union)

The denominator is the union of both users' connected sources. If User A has Spotify and User B has Spotify + Steam + GitHub + Untappd + Goodreads, the denominator includes all five.

Σ(SignalWeight_union × CategoryMultiplier_union)
The sum of weights for every distinct source connected by either user. Missing sources contribute to the denominator but not the numerator, pulling the score down.
The asymmetry problem:

While both users see the same headline score (the denominator is identical for both), the score is dominated by the user with more integrations. With a perfect Spotify match:

  • Score = 0.85 / 4.55 = 19% for both users

This is technically bidirectional, but the result is functionally the same as R1 for User B. The sparse user is severely penalised for data they do not have. A perfect alignment on the one source they share is treated as a near-failure because of sources that were never evaluated.

This approach is structurally unfair to new users and users who are selective about which platforms they connect. It creates a system where matching quality is gated by data quantity, which conflicts with privacy-first design (a user should be able to connect only what they are comfortable sharing without being algorithmically penalised).

Technically this method is bidirectional (both see 19%), but the score is misleading. It conflates "we do not have data" with "you are not compatible". We classify it as functionally non-bidirectional because the experience is indistinguishable from R1 for the data-rich user - they see the same depressed score regardless.

R3 Asymmetric weighted average Non-bidirectional
Score_A = α × SharedScore + (1 - α) × UnconnectedPenalty_A
Score_B = α × SharedScore + (1 - α) × UnconnectedPenalty_B

UnconnectedPenalty_X = 1 - (numUnsharedSources_X / totalSources_X)
where α is a blending factor (i.e. 0.7)

Each user's score blends the shared-source score with a penalty based on how many of their own sources are unmatched. A user with 5 sources, 1 shared, gets a higher penalty than a user with 1 source, 1 shared.

α
Blending factor controlling how much weight is given to the shared score vs the coverage penalty. Higher α means the shared score dominates; lower α means coverage matters more.
UnconnectedPenalty_X
The fraction of User X's sources that are shared. If a user has 5 sources and only 1 is shared, the penalty is 1 - 4/5 = 0.2. If a user has 1 source and it is shared, the penalty is 1 - 0/1 = 1.0 (no penalty).
The asymmetry problem:

With α = 0.7, SharedScore = 85%, and the same scenario:

  • User A (1 source, 1 shared): Score = 0.7 × 85 + 0.3 × 100 = 90%
  • User B (5 sources, 1 shared): Score = 0.7 × 85 + 0.3 × 20 = 66%

The asymmetry is slightly less extreme than R1 (90% vs 66% rather than 85% vs 19%), but it is still fundamentally broken. Two users looking at the same match see a 24-point gap. The α parameter is arbitrary, and different α values trade off between fairness to sparse users and fairness to data-rich users without ever solving the core problem.

Worse, this approach embeds a philosophical inconsistency: it simultaneously claims to reward connecting more data (via the coverage penalty) while punishing users who do so (via the expanded denominator). A user who connects their fifth source sees their scores across all matches potentially decrease.

R4 Maximum-of-both normalisation Non-bidirectional
Score = Σ(Affinity × NicheWeight × SignalWeight × CategoryMultiplier) / max(Denom_A, Denom_B)

Denom_X = Σ(SignalWeight × CategoryMultiplier) for all sources User X has connected

The denominator is the larger of the two users' individual denominators. This ensures the score is bounded by the more data-rich user's scale.

max(Denom_A, Denom_B)
Takes the maximum of both users' total source weights. If A has 1 source (denom 1.0) and B has 5 (denom 4.55), the denominator is 4.55.
The problem:

Both users see the same score (19% in our running example), so this is technically bidirectional. But it inherits the same fundamental flaw as R2: it treats missing data as zero compatibility. A perfect alignment on shared sources is buried by the weight of unconnected ones.

Additionally, this approach creates a cliff effect when users connect or disconnect a source. If User B disconnects four sources, the denominator drops from 4.55 to 1.0 and the score jumps from 19% to 85% - a jarring change that has nothing to do with compatibility and everything to do with data quantity. Scores that swing wildly based on integration management rather than relationship quality are not trustworthy.

R5 Harmonic mean of per-user scores Non-bidirectional
Score_A = SharedNumerator / Denom_A
Score_B = SharedNumerator / Denom_B

HarmonicScore = 2 × Score_A × Score_B / (Score_A + Score_B)

Compute each user's per-user score (as in R1), then combine them using the harmonic mean. The harmonic mean is pulled toward the lower of the two values, which penalises large discrepancies.

Harmonic mean
2ab / (a + b) - always less than or equal to the arithmetic mean. If a = 85 and b = 19, the harmonic mean is 31.1, much closer to the lower value.
The problem:

The harmonic mean score is symmetric (both users see 31%), which solves the display problem. But the resulting score is dominated by the data-rich user's denominator. In our example, the harmonic mean of 85% and 19% is 31% - lower than either user's shared-source score (85%). The approach systematically produces scores that are worse than the actual compatibility on shared data.

The fundamental issue is that the harmonic mean does not distinguish between "low compatibility" and "low data coverage". A pair with genuinely poor compatibility across 5 shared sources (Score_A = 25%, Score_B = 25%, harmonic = 25%) looks identical to a pair with excellent compatibility on 1 of 5 sources (Score_A = 85%, Score_B = 19%, harmonic = 31%). These are qualitatively different situations that deserve different scores.

This approach also introduces a computational dependency: you must first compute per-user scores (which are asymmetric and meaningless individually) and then combine them. The intermediate per-user scores serve no purpose except as inputs to the harmonic mean, adding complexity without clarity.

R6 Missing-source penalty function Non-bidirectional
Score_A = SharedScore × (1 - λ × missRatio_A)
Score_B = SharedScore × (1 - λ × missRatio_B)

missRatio_X = unsharedSources_X / totalSources_X
where λ controls penalty severity (i.e. 0.5)

Each user's score is the shared-source score discounted by a penalty proportional to how many of their own sources are unmatched.

λ
Penalty severity factor. At λ = 0.5, a user with 4 of 5 sources unmatched (missRatio = 0.8) sees their score multiplied by 0.6. A user with 0 unmatched sources sees no penalty (multiplier = 1.0).
missRatio_X
The fraction of User X's connected sources that are not shared with the other user. Ranges from 0.0 (all sources shared) to approaching 1.0 (almost no sources shared).
The asymmetry problem:

With λ = 0.5, SharedScore = 85%:

  • User A (missRatio 0.0): Score = 85 × 1.0 = 85%
  • User B (missRatio 0.8): Score = 85 × 0.6 = 51%

A 34-point gap for the same pair. The λ parameter is a free variable with no principled way to set it - any value is a compromise between penalising sparse users too much (λ = 1.0 is devastating) and not penalising at all (λ = 0.0 collapses to Method 1).

This approach also has a perverse incentive structure: User B can increase their score by disconnecting sources. Removing Steam, GitHub, Untappd, and Goodreads changes their missRatio from 0.8 to 0.0, boosting the score from 51% to 85%. The algorithm rewards users for reducing their data footprint, which directly conflicts with the platform's goal of richer, more confident matching.

7. Design principles

The algorithm design is guided by five principles, all of which informed the method selection documented above:

  1. Bidirectionality. Both users must see the same score for the same pair. No exceptions. This is a trust requirement, not a nice-to-have.
  2. Transparency. Every component of the score must be explainable in plain language. Users should be able to understand why a match was suggested and verify the reasoning against their own data. No hidden weights, no opaque models, no engagement-optimised ranking.
  3. No penalty for privacy. A user who connects one integration must not be algorithmically punished compared to a user who connects five. Connecting fewer sources means less data to score, but it should not mean lower scores on the data that is available.
  4. Niche over mainstream. Shared tastes that are rare should contribute disproportionately more signal than shared tastes that are common. Both liking a band with 12 listeners is a stronger compatibility indicator than both knowing the number-one chart hit.
  5. Depth over breadth. Two users who are both deeply engaged with a shared item (high listening time, many check-ins, high ratings) should score higher than two users who both technically "have" the item but barely interacted with it. Presence is the floor, not the ceiling.

Method 1 (shared-source-only normalisation) is the only approach among the twelve evaluated that satisfies all five principles simultaneously. It is simple, bidirectional, privacy-respecting, and lets the Affinity and NicheWeight components do the work they were designed to do.