Chart Affinity API

Chart Affinity API

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.

How much comes back

live API · one person · 15 May 1980, London · 22 Sep 2026

layerrowswhat they are
Birth chart144115 aspects, 28 house placements, 1 oriental
Transit388366 aspects, 22 house placements
Draconic11595 aspects, 20 house placements
Progressed9349 aspects, 28 signs, 16 houses
One person, four layers740
Synastry, if partnered356a 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.

Two findings that decide the design

1 — two thirds of the chart is not in the components collection

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.

2 — affinity alone will not give you this feature

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.

Cost: pass the factors, store nothing

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:

stepcost
Engine build, corpus + this chart (boot / per chart)1,007 ms
Ad-hoc decomposition of all 740 names21 ms
Archetype tally — linear2 ms
Stage 1 prefilter: 273,430 pairs → 959 candidates37 ms
Stage 2 full scoring of 959 → 561 links at 85%+31 ms
Per request, after the engine is warm68 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.

What to actually return

three outputs · only the third is quadratic

1. Recurring archetypes — the headline

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.

2. Cross-layer echoes — the sharpest rows

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.

3. Cross-route links — where affinity belongs

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.

Live test — four real charts

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
but the 85% cut is wrong for this case

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:

cutavg poolemptyshare a body or the whole signature
851.950 / 40397%
802.635 / 40395%
754.419 / 40393%
7010.118 / 40394%
6516.317 / 40391%

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.

What is still missed at 70, and whether it should be

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.

The solution: ask it the other way round

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:

personfactorsthemesspanning 3+ layers
Ana73032485
Kenji73934467
Nadia71833862
Sam750——

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.

API shape

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": []
}

Open, and I would not guess

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.