Finding related factors inside one person’s chart — across natal, progressed, draconic and transit — rather than between two people. Every figure below is measured against the live CalculateHoroscopes API and the current engine.
live API · one person · 15 May 1980, London · 22 Sep 2026
| layer | rows | what they are |
|---|---|---|
| Birth chart | 144 | 115 aspects, 28 house placements, 1 oriental |
| Transit | 388 | 366 aspects, 22 house placements |
| Draconic | 115 | 95 aspects, 20 house placements |
| Progressed | 93 | 49 aspects, 28 signs, 16 houses |
| One person, four layers | 740 | |
| Synastry, if partnered | 356 | a separate call, per partner |
Every transit row carries Begins and Ends. On the query date only 211 of 388 transits are active and 9 begin within 90 days — so the honest working set is about 570, not 740, and your “upcoming Venus opposition Uranus” is one of those 9.
Of the 144 birth-chart rows, only 49 exist as stored components. The house rows come back as Sun in Taurus in 9th House — planet, sign and house in one name — and the corpus stores two-part names. This is the same thing lookup_misses has been recording at ~4,100/day.
It does not block this feature. All 740 decompose to genes anyway, in 21ms, because the engine reads the NAME, not a database row — and the compound name is actually richer, carrying both the sign’s and the house’s archetypes. So the affinity API must decompose ad hoc and must never require the component to exist.
Your worry was right, and here is the number. Your own example:
37% Venus Square Mars <-> Venus Opposition Natal Uranus
Not a bug. Affinity asks are these the same pattern, and these share one function of two — Love — while the other end differs (Will against Awakening). For matching two people that low score is correct.
Inside one chart it is the wrong question. “Your Venus keeps getting hit” is the real observation, and no pairwise sameness score surfaces it, because no two of those factors are the same pattern. The intra-chart question is which archetype recurs — a tally, not a pairing.
answering: all-to-the-matcher, or wide pools then filter
The full matrix of 740 factors is 273,430 pairs, which at full scoring is seconds — too slow. A two-stage pass fixes it, measured end to end on the real list:
| step | cost |
|---|---|
| Engine build, corpus + this chart (boot / per chart) | 1,007 ms |
| Ad-hoc decomposition of all 740 names | 21 ms |
| Archetype tally — linear | 2 ms |
| Stage 1 prefilter: 273,430 pairs → 959 candidates | 37 ms |
| Stage 2 full scoring of 959 → 561 links at 85%+ | 31 ms |
| Per request, after the engine is warm | 68 ms |
Stage 1 keeps only pairs sharing two or more functions, and drops pairs that are the same body restated. It removes 99.6% of the work for nothing of value, because a pair sharing fewer than two functions cannot reach the cut anyway.
So: pass the user’s factor list and compute in memory. Precomputing wide pools per component would buy storage, staleness and a filtering step, to replace 68 ms of arithmetic. The only thing worth holding in memory is the engine itself.
three outputs · only the third is quadratic
Straight from the real chart above, in 2ms:
Love x80 Expansion x79 Mind x70 Self x69 Will x69 Awakening x64
This is the “wider span” you were reaching for. It needs no threshold and no pairing.
The same normalised body pair appearing in two or more layers. Exact match, no scoring. A correction to your premise here: within one layer a body pair holds only one angle, so you are right that nobody has Moon square and opposition Pluto — but across layers the same pair recurs constantly, and that repetition is the strongest thing this feature can show.
The two-stage pass. Be warned that 561 links from 740 factors is more than anyone can read, and the top of the list is thin:
100% Transit Mercury in 1st House <-> Mars Sextile Natal Mercury
Both contain Mercury, so they score perfectly while telling the reader little. Cap this output and rank it by something other than score — for example, prefer links that cross layers or routes over links inside one layer.
4 people · 16 API calls · 403 query factors swept
Four birth charts pulled from the live API (Buenos Aires 1974, Tokyo 1991, Cairo 1966, Vancouver 2001), 718–750 factors each. A factor was picked from each chart and scored against the rest of that person’s own chart.
Every returned row was defensible. The engine found the same body pair in other layers, and found placements that carry the pattern through rulership without naming the planet:
Draconic Saturn Square Neptune (Kenji, draconic)
87% Saturn Square Natal Neptune transit the same pair, now
86% Saturn in Aries in 12th House draconic
83% Progressed Neptune in Capricorn progressed Capricorn IS Saturn
78% Neptune in Capricorn in 9th House natal so is this
The four pools came back at 2, 4, 0 and 1. One returned nothing at all. And the near-misses were things that plainly belong — Sam’s Saturn Inconjunct South Node sat at 84%, one point under, and it is the natal version of the very pair queried.
Sweeping every 7th factor of all four charts — 403 real queries:
| cut | avg pool | empty | share a body or the whole signature |
|---|---|---|---|
| 85 | 1.9 | 50 / 403 | 97% |
| 80 | 2.6 | 35 / 403 | 95% |
| 75 | 4.4 | 19 / 403 | 93% |
| 70 | 10.1 | 18 / 403 | 94% |
| 65 | 16.3 | 17 / 403 | 91% |
Quality barely moves between 85 and 70 while the pool grows five-fold, because the cut is a property of the candidate pool, not of the engine. 85 was calibrated against 16,000 corpus components, where a loose cut admits thousands of strangers. A chart is ~740 factors that are already all about this one person, so the same score means something different. Pass cut: 70 for intra-chart; leave 85 for people-matching.
The remaining near-misses are one class: the same body in a placement whose other end differs — Transit Chiron in 3rd House at 62% against a Chiron query, Transit Lilith in 3rd House at 62% against a Lilith one. They share the body but not the pattern. Whether “your Chiron is also busy over here” belongs in a related list is a product call, not a scoring one — and it is answered by output 1, the archetype tally, rather than by lowering the cut further.
One of the four returned a single row even at 70: a Lilith–Jupiter query, because Shadow is genuinely rare across that chart. That is honest scarcity and the UI needs to handle it.
theme → factors, not factor → factors
Everything above points one way. Asking “what relates to this factor?” is thin by nature — a chart rarely contains two factors with the same signature, so the pool is 1.9 at the calibrated cut and empty one time in eight. Asking “what recurs across this chart?” is always populated, because recurrence is what a chart is full of.
The unit is the one the matching engine already uses: an unordered pair of functions. Group the person’s own factors by it, keep the themes appearing in three or more layers, rank by layer span then by count. Measured on the four live charts:
| person | factors | themes | spanning 3+ layers |
|---|---|---|---|
| Ana | 730 | 324 | 85 |
| Kenji | 739 | 344 | 67 |
| Nadia | 718 | 338 | 62 |
| Sam | 750 | — | — |
The same content the factor-first query returned, but grouped and readable — here is Kenji’s, the theme his random factor belonged to:
Dissolution x Structure 7 factors across 4 layers
birthchart Neptune in Capricorn in 9th House
transit Transit Saturn in 12th House · Neptune Sextile Natal Saturn (+1)
progressed Progressed Neptune in Capricorn
draconic Saturn in Aries in 12th House · Draconic Saturn Square Neptune
And it rescues the empty case. Nadia’s factor-first query returned nothing at all; theme-first she has 62 themes across three or more layers, led by Love × Will with 11 factors. A person is never told “nothing relates to this”.
So: theme-first is the feature; factor-first affinity is the drill-down. Tap a theme to see the factors carrying it; tap a factor to ask what else in the chart resembles it, at cut: 70. The first view is a linear group-by over the user’s own list — 2 ms, no matcher call at all. The matcher is only needed for the drill-down, and that is where the 68 ms two-stage pass earns its place.
POST /affinity/chart
{
"factors": [
{"name": "Venus Square Mars", "category": "Planet Aspects"},
{"name": "Sun in Taurus in 9th House", "category": "Planets in Houses"},
{"name": "Venus Opposition Natal Uranus", "category": "Transit Aspects",
"begins": "2026-10-04", "ends": "2026-10-19"}
],
"activeOn": "2026-09-22",
"include": ["archetypes", "echoes", "links"],
"cut": 85, "maxLinks": 40
}
{
"archetypes": [{"function":"Love","count":80,"factors":[...]}],
"echoes": [{"pair":"Mars–Venus","layers":["natal","draconic"],"factors":[...]}],
"links": [{"a":"...","b":"...","score":93,"crossesLayer":true}],
"unresolved": []
}
category. The same name lives in several layers; without it the engine takes the first match and may read a synastry contact as natal.activeOn is what separates 211 live transits from 388 rows of mostly-not-now.Pluto Square Mars and Moon Trine Sun resolved to nothing because the corpus stores those pairs in the other order — they did not error, they silently vanished. On a feature whose whole job is seeing the entire chart, silent loss is the worst failure mode.The tally has no baseline. “Love × 80” may just mean this chart is large, or that Love is common in everyone’s. It wants normalising against the average chart, which nobody has measured. Ship it raw, look at real output across a few hundred charts, then decide.
Output 3 needs a product decision, not a threshold. 561 links is noise; the fix is what you rank by, and that depends on what the feature is for.