Interpretation layer
Caelus is a fact engine. Every module stops at validated geometry and says so: a chart gives you bodies, aspects, angles, cusps, dignities, and patterns, with no flavour text and no "what it means." That is deliberate: the numbers are correct and auditable, and astrology's meaning is a matter of tradition and taste, not arithmetic.
But it leaves a gap. If you want to generate an interpretation (a rule-based reading, or an LLM that writes natal prose), you have nowhere clean to plug in. The interpretation layer is that seam. It does not interpret. It defines the contract between the facts the engine owns and the meaning you bring:
The engine owns the facts. A layer you plug in owns the meaning. The framework owns the contract between them. Never the content.
Engine (validated) chart(): bodies, aspects, angles, cusps
→ Fact projection interpretationContext(chart): ranked, citable atoms
→ Matching selectors over atoms (each match carries its provenance)
→ Sources your rule corpus + interpret() / reconcile()
→ Output a structured reading, or an LLM brief + citation audit
Each layer is independently useful and consumes only the one below it. Use as much of the stack as you need: stop at the atoms and feed your own model, or run the whole pipeline to a reconciled, cited reading.
What it's for
- Ground an LLM in correct math. The hardest part of an AI astrologer is not
the prose (models write fluent prose). It is that they hallucinate positions.
Hand the model the validated facts, each tagged with a stable id, and require
it to cite the id behind every claim. The chart math was never the model's to
invent, and
auditCitationsproves it didn't. - Build a rule-based reading engine. Express a tradition or house style as selectors over the fact model ("Mars in an angular house and part of a T-square") and get back ranked statements, each carrying the exact atoms that justified it.
- Rank and reconcile. Salience surfaces what is prominent;
reconcilegroups everything said about the same placement and flags where your corpus contradicts itself.
1. Project the chart into fact atoms
interpretationContext(chart) flattens a Chart into a flat, ranked list of
typed fact atoms. An atom is the unit an interpreter reasons about and cites.
Every atom carries five common fields:
id: a stable, content-addressable string (placement:mars,aspect:mars~saturn:square,pattern:t_square:mars-moon-saturn,signature:element:fire,angle:asc). A generated claim points at this id, so it can always be traced back to the fact it rests on.kind:placement | aspect | pattern | signature | angle | dispositor | reception | star | lot | transit | synastry | composite | timelord | dignity | nakshatra | varga | yoga.bodies: the body ids involved, for filtering and cross-reference.salience: a transparent, overridable prominence score (below).text: a plain-language statement of the fact, with no interpretation ("Moon conjunction Neptune (applying, orb 0.6°)").
Reception atoms record mutual reception by domicile, exaltation, or
the sect's triplicity ruler (or a mixed pair, e.g. domicile-exaltation).
Use hasReception({ body: "venus" }) to match any involving Venus.
Star and lot atoms are not computed from a bare Chart (the star
catalog lives in the data pack, and lots derive from the points and sect), so a
caller supplies them through ContextOptions: a body's tight conjunction with a
fixed star (star:jupiter:Sirius, from Engine.starConjunctions), and the
Hermetic lots — the Part of Fortune and its companions (lot:fortune, from
Engine.lots). Match them with hasStar({ star: "Algol" }) and
hasLot({ lot: "fortune" }).
Diachronic and relational atoms
The natal projection extends via ContextOptions and two helpers:
enrichContextOptions(engine, chart, target)— transits to natal (with phase and strength), active profection / zodiacal releasing / firdaria / Vimshottari dasha, Egyptian term/face/triplicity/almuten facts (always), and nakshatra / D9 / yogas when the chart is sidereal.enrichSynastryOptions(engine, chartA, chartB)— inter-chart aspects, house overlays, and composite midpoint placements (chart A is the base).
Id examples: transit:saturn~natal_moon:square, profection:year:scorpio:mars,
zr:l1:scorpio:mars, dasha:maha:saturn, term:moon:saturn,
synastry:mars~b_venus:square, composite:mars, nakshatra:moon:Ashwini,
varga:d9:moon:virgo, yoga:Gajakesari. Match with hasTransit,
hasTimelord, hasSynastry, hasComposite, hasDignityFine, hasNakshatra,
hasVarga, and hasYoga. Every id resolves in auditCitations.
The Playground Reading tab and MCP chart_facts (with optional
target_date) use enrichContextOptions automatically. MCP synastry
returns the same citable atoms plus a ready brief.
Kind-specific fields fill in the rest: placements add
sign/signDeg/house/retrograde/dignities; aspects add
aspect/orb/phase/strength; patterns add pattern/apex; signature
facets add facet/value; dispositors add dispositor/final; stars add
body/star/orb; lots add lot/sign/house.
Two enrichments that the bare Chart.aspects list omits are computed here (the
engine now fills them onto the aspect, the projection carries them onto the atom):
phase: applying, separating, or exact, from the two bodies' speeds.strength: closeness in[0, 1](1 = exact, 0 = at the orb limit), normalized against the orb policy used to find the aspect.
Salience
Salience ranks atoms so a reader leads with what is prominent, without the
engine asserting meaning. It is a sum of explicit, documented contributions,
all overridable through ContextOptions.salience:
| Contribution | Applies to |
|---|---|
base | every atom |
luminary | the Sun or Moon is involved |
angular | an angular house (1/4/7/10), or an angle atom |
chartRuler | the Ascendant ruler's placement |
dignity | per essential dignity held |
hardAspect | conjunction / square / opposition |
pattern | a whole configuration |
dispositor / reception | a dispositor link / a mutual reception |
star / lot | a fixed-star conjunction / a Hermetic lot |
transit / synastry / composite | a transit aspect / inter-chart link / midpoint placement |
timelord | an active profection, ZR, firdaria, or dasha period |
dignity / vedic | finer essential dignity / nakshatra, varga, or yoga |
No weight is magic. A caller who dislikes the defaults overrides any subset:
This is framework code, not ephemeris. There is no Swiss Ephemeris oracle for "which facts matter," so it is unit-tested for structure (atoms tie back to the validated chart, ids are unique, salience is sorted) rather than pinned by a parity golden.
2. Match atoms with selectors
A selector tests the whole projection and reports the atoms that satisfied
it. Because selectors read the atoms directly, they express the whole fact model
(house, dignity, pattern membership, signature dominance, aspect phase and
strength), which the geometric, time-only when() predicates
cannot.
matchAll requires every selector and unions their atoms; matchAny requires
one and returns those that matched; matchNone is an absence test (matches when
its selector does not, and carries no atoms).
3. Plug in a rule corpus
A Rule pairs a selector (when) with the text it licenses. An
InterpretationSource bundles rules: a tradition, a house style, or a
third-party corpus. interpret(ctx, sources) runs every rule and, for each
match, emits a ReadingEntry carrying the text, the matched atomIds (the
audit trail), and a salience equal to the sum of those atoms' salience times the
rule's weight. Entries come back sorted by salience.
The engine ships the mechanism; the rule content is always yours. The corpus below is illustrative placeholder content, not authoritative astrology.
If you would rather not start from scratch, the companion package
caelus-delineations-pd is a ready default and validation corpus: hundreds
of cited delineations (planet-in-sign and house, aspects, rising signs, fixed
stars) decomposed from public-domain texts into these selectors. Drop its
sources straight into interpret(ctx, sources), or import publicDomainSources
for a strictly public-domain reading. See Building a corpus for
how to consume it, write extractors, and validate your own passages. It is a
separate package, never the engine.
Reconcile
A flat ranked list scatters everything said about one placement. reconcile
groups entries by the facts they share (atoms in common), drops duplicate text,
and marks a group contested when a declared conflicting tag-pair both appear in
it. Semantic contradiction is the corpus author's to declare (via tags +
conflicts); the resolver does the bookkeeping, not the judgement.
4. Output: a reading, or an LLM brief
There are two consumers of the projection.
A structured reading: the ranked interpret(...) entries (or reconciled
groups), with provenance, for a rule-based product. You already have it from
step 3.
An LLM brief: chartBrief(ctx, opts) renders the salience-ranked,
id-tagged facts into a ready-to-send prompt. The model writes original prose
(novel) and cites the [id] each statement rests on; auditCitations then
checks those citations resolve, flagging any that invented a fact (accurate).
auditCitations returns whether every cited id resolves (ok), how many claims
were cited vs uncited, and the distinct valid and unknown ids. An
unknown id is fabricated provenance: the model asserted a fact the chart never
contained.
5. Provenance framing
Charts are not always a verified birth instant. A forecast, a mythic subject, or
a time known only to the hour need different interpretation framing than an
observed event. The provenance guide declares what a chart
is (Realm) and how firmly its instant is known (Certainty).
Wire it from realize():
When certainty is not exact, time-sensitive atoms are damped: Moon and
angle salience is scaled by 0.7 (approximate) or 0.6 (representative); slow
planets keep full weight. chartBrief prepends a realm framing line when
needed (a forecast reads as provisional; a mythic chart as a symbol, not a
biography; an inexact time warns the model to distrust the Moon, angles, and
houses).
Over MCP
The whole seam is wired into the MCP server as the chart_facts
tool, for hosts where the model is already the interpreter (Claude, Cursor, and
the rest). It returns a chart's ranked, citable atoms plus a ready brief, so
the host interprets from correct math and cites the [id] each statement rests
on instead of re-deriving (and hallucinating) positions. Pass structured
when (instant, range, relative, narrative), an optional in-request anchors
registry, realm, and constraints when the chart is not a plain birth instant.
Optional target_date (UTC ISO) adds transits and time-lords at that
instant; include_vedic adds nakshatra/D9/yoga when the zodiac is sidereal.
The synastry tool returns the same atom shape for two charts (inter-chart
aspects, overlays, composite placements) plus a brief.
"Read my natal chart: born June 10 1990, 2:30pm, Tampa FL."
→ chart_facts → ranked facts + brief → the host writes prose, citing [ids]
When to use which layer
- Just the atoms (
interpretationContext): you have your own model or ranking and only need the validated facts, normalized and addressable. - Selectors + a rule corpus (
interpret/reconcile): you are building a deterministic, rule-based reading engine and want provenance and conflict detection for free. - The brief (
chartBrief/auditCitations): you are using an LLM and need it grounded: novel prose, accurate facts, citations you can verify. - Non-birth charts (provenance +
realize): forecasts, fiction, archetypes, or inexact times. Declare the realm, route to ephemeris or the compiler, and passcertaintyso damping and framing stay honest.
In every case the engine ships the contract and the math, never the meaning. The interpretation is yours, and it is always traceable to a validated fact.