Astrology11 MIN READ53 / 116

Solar Return solar@1 — Specification (W-05)

Status: Implemented v1 convention · Task: W-05 · Updated: 2026-10-25 · Implementation: W-08 package.

A return is the instant a body reaches a target ecliptic longitude again, followed by the chart for that instant. Two separate jobs live here: a bounded root search through Composer probes, and an event-chart request under an explicit location policy. The search must never present a sampled grid point as a solved event, and the event chart must never assume a location.

1. Admitted variant

Field Value
Technique returns
Version 1
Variant solar
Rulebook entry { "technique": "returns", "version": "1", "variant": "solar", "hash": "<payload hash>" }

Rule payload (validated by validateReturnRules):

{
  "body": "sun",
  "target": "reference-longitude",
  "solver": "bisection",
  "gridStepMilliseconds": 21600000,
  "toleranceMilliseconds": 1000,
  "toleranceDegrees": 0.0001,
  "maxEvents": 1,
  "maxProbesPerEvent": 64,
  "halfWindowDays": 3,
  "maxHalfWindowDays": 15
}

body is sun only in v1. A manifest admits one entry per technique, so a lunar or planetary return is a different bundle and variant, not a request parameter.

2. Inputs

Input Source Meaning
Reference snapshot Engine Supplies the target longitude and the birth context
anchorInstant Request Centre of the search window (typically the birthday anniversary)
locationPolicy Request Where the event chart is cast — required, no default
Injected ProbePort Engine (P2) Bounded position evaluation for the search
Injected ComposerPort Engine (P0) Composes the event chart
Profile Engine Zodiac, position origin, house system, node model

3. Target longitude

L0 = reference snapshot Sun longitude, degrees in [0, 360)

L0 is read from the admitted reference snapshot; it is never recomputed from birth data, rounded by the technique, or overridden by the caller. If the reference has no Sun, the technique is INSUFFICIENT_INPUT. A caller-chosen target longitude is a different variant (§12).

4.1 Window

halfWindow = request.halfWindowDays ?? rules.halfWindowDays
start      = anchorInstant - halfWindow × 86 400 000
end        = anchorInstant + halfWindow × 86 400 000
span       = end - start

The window is inclusive at both ends. Validation, before any acquisition:

Check Failure
halfWindow > rules.maxHalfWindowDays LIMIT_EXCEEDED
end - start > ProbeBudget.maxWindowMilliseconds LIMIT_EXCEEDED
Window outside ProbeBudget.start/end LIMIT_EXCEEDED
Samples floor((end - start) / step) + 1 + (span % step === 0 ? 0 : 1) > ProbeBudget.maxEvaluations LIMIT_EXCEEDED

The window is never clipped to fit a budget. A smaller window is a caller decision.

4.2 Grid

step = request.gridStepMilliseconds ?? rules.gridStepMilliseconds

step must be ≥ ProbeBudget.minimumStepMilliseconds; a smaller value is LIMIT_EXCEEDED, never silently raised. Samples are start, start + step, … , end with the final sample at end even when the spacing is shorter. Samples are requested through ProbePort.series; no interpolation is performed or accepted.

4.3 Residual

r(t) = wrap180(λ_body(t) - L0),  range (-180, 180]

wrap180 is defined in the composite specification §3.1. The wrapped residual is what makes the search wrap-safe.

4.4 Bracketing

For consecutive samples a < b:

Condition Action
r(a) === 0 Exact event at a; no refinement
sign(r(a)) !== sign(r(b)) and abs(r(b) - r(a)) <= 180 Bracket [a, b]
abs(r(b) - r(a)) > 180 Longitude wrap (or a discontinuity): not a crossing; continue
sign(r(a)) === sign(r(b)) No crossing in this step

The |Δr| > 180 guard is the difference between a return and a representation artifact. A raw λ - L0 comparison would report a false event every time the body crosses 0° away from L0; that is the defect the third row prevents.

A crossing that occurs inside one grid step is found by the step size, not by assumption. The Sun moves at most ~1° per day, so a 6 h step cannot hide a solar return; a body with unknown speed or a retrograde station (the turning point where apparent motion reverses) may hide one, which is why the retrograde fixtures exist.

4.5 Refinement

Bisection on the bracketed sign change:

lo, hi = bracket
loop:
    best = endpoint with the smaller |r|
    if (hi - lo <= toleranceMilliseconds) and (|r(best)| <= toleranceDegrees):
        return solved(best)
    if probesUsed >= maxProbesPerEvent:
        return exhausted(lo, hi)
    mid = floor((lo + hi) / 2)
    probe(mid)
    replace the endpoint whose residual has the same sign as r(mid)
  • Both tolerances must be satisfied: the time bracket is not sufficient on its own, and the angular residual is not sufficient on its own.
  • The event instant is the endpoint with the smaller |r|; its residual and the bracket width are both retained.
  • maxProbesPerEvent is bounded by the remaining ProbeBudget.maxEvaluations; whichever is smaller wins. Exhausting either is exhausted, never a snapped answer.

5. Candidate selection

Situation Outcome
Exactly one solved crossing { status: 'solved', event }
No crossing in the window { status: 'no-event', searched }
More than maxEvents crossings { status: 'ambiguous', events }
Probe budget exhausted { status: 'exhausted', consumed }
  • Events are sorted by instant; two events within toleranceMilliseconds of each other are the same event and are deduplicated (keep the one with the smaller residual).
  • ambiguous is a value, not an error: a retrograde body can legitimately cross its target three times in one window. The result carries every solved event so the caller can choose.
  • no-event is a successful result. It carries the searched window, the grid step, the probe count and the residual range, so “nothing happened” is auditable.
  • exhausted carries the consumed evaluation count and the unresolved bracket; it is distinct from LIMIT_EXCEEDED, which is raised before acquisition when the declared limits cannot be met.

6. Location policy

locationPolicy is required and has no default:

Policy Event chart observer Search observer (topocentric)
{ kind: 'birth' } Reference chart’s houseObserver Reference chart’s positionObserver
{ kind: 'current', observer } Supplied observer Supplied observer
{ kind: 'event', observer } Supplied observer Supplied observer
  • birth with no reference houseObserver is INVALID_REQUEST; no birth place is invented.
  • current and event differ only in intent and in the label retained in the result. Both require an explicit WGS84 observer.
  • Geocentric profile: the search is location-independent (the event instant does not depend on the observer); only the event chart uses the policy observer. Both the search origin and the event observer are recorded.
  • Topocentric profile: the search is location-dependent (parallax changes the longitude). The policy observer is pinned before the search, used for every probe, and retained as searchObserver. The same request with a different location legitimately yields a different event instant; that is reported, not hidden.
  • The policy is never inferred from the anchor instant, the reference chart’s civil timezone or a “likely” location.

7. Event chart

After an event is solved, the event chart is composed once:

toChartContextInput(profile, {
  instant: event.instant,
  houseObserver: policyObserver,
  positionObserver: profile.positionOrigin === 'topocentric' ? searchObserver : undefined
}, { houses: true })

The event chart is observed astronomy at the event instant, not a derived chart. It retains its own snapshot hash and Binder evidence. Nothing about it is labelled derived.

The event chart is not composed for no-event, ambiguous (until the caller picks an event) or exhausted outcomes.

8. Result

Field Meaning
target { body: 'sun', longitude: L0, reference: <snapshot hash> }
window { start, end, gridStep, halfWindowDays }
outcome One of the four §5 outcomes
event? { instant, residualDegrees, bracketMilliseconds, probes, retrograde }
eventChart? { snapshot: <hash>, context, evidence } for a solved event
location { policy, searchObserver, eventObserver } as resolved
solver { id: 'bisection', version: '1', toleranceMilliseconds, toleranceDegrees }
probes { consumed, evaluations } from the probe session

retrograde is true when the sampled speed at the solved instant is negative and null when the speed is unknown — it is never defaulted to false.

9. Composer facts and ports

Need Provided by Absent behaviour
Reference Sun longitude and context Reference snapshot INSUFFICIENT_INPUT
Position evaluation across the window ProbePort (P2) missing-capability (probes)
Event chart ComposerPort (P0) missing-capability (positions)
House cusps for the event chart ComposerPort.compose with houses FACTOR_UNAVAILABLE (houses)
Aspect geometry Not used — returns do not evaluate contacts —

Declared requirements: snapshot: 'none', composer: true, upstream: ['positions', 'probes'], optionalUpstream: ['houses'], dependencies: [].

Probe session ownership. WesternUpstreamBindings.probe is an already-open session, not a factory. The engine must open it with the technique’s declared window, step and evaluation limits (or hand the feature ComposerPort.openProbeSession), otherwise the technique cannot enforce §4.1 before its first read. See index P2.

10. Failures

Case Behaviour
Missing/absent anchorInstant or locationPolicy INVALID_REQUEST
birth policy with no reference house observer INVALID_REQUEST
current/event policy without an observer INVALID_REQUEST
Unknown body, solver or target INVALID_RULES
halfWindow, window span or step outside the budget LIMIT_EXCEEDED before any read
Grid larger than maxEvaluations LIMIT_EXCEEDED before any read
Probe read fails Upstream error preserved; no partial event
Refinement budget exhausted { status: 'exhausted' } with the unresolved bracket
No crossing { status: 'no-event' }
More than maxEvents crossings { status: 'ambiguous' } with all solved events
Event chart compose fails Upstream error preserved; the solved instant is not returned as a chart

11. Worked examples

Synthetic Sun model used by the fixtures: λ(t) = wrap360(NAIBOD × d), where d is days since 2020-01-01T00:00:00.000Z and NAIBOD = 0.9856473590852142 °/day. L0 = 100°.

R1 — solved event. Window 2020-04-11T00:00:00Z ± 3 days, step 6 h.

r(101.25 d) = 0.9856473590852142 × 101.25 - 100 = -0.203204892622°
r(101.50 d) = 0.9856473590852142 × 101.50 - 100 = +0.043206947149°

bracket 2020-04-11T06:00:00Z .. 2020-04-11T12:00:00Z
event   2020-04-11T10:56:52.560Z
residual 0.000000° (within 1e-4), bracket < 1000 ms, direct

R2 — wrap, not a return. Target 100°, body moves 350° → 10° at 1°/day over 20 days. wrap180 residual goes +110° → -90°: no sign change, { status: 'no-event' }. A raw λ - 100 difference goes 250 → -90 and would fabricate a crossing; the fixture asserts the wrapped result.

R3 — retrograde triple crossing. Piecewise synthetic body: +1°/day to day 10 (λ=10), -0.5°/day to day 30 (λ=0), +1°/day to day 40 (λ=10). Target 5°.

day 5   λ=5   direct
day 20  λ=5   retrograde
day 35  λ=5   direct

With maxEvents: 1 the outcome is ambiguous with three solved events; with maxEvents: 3 it is solved only if the technique variant admits three, which solar@1 does not. The fixture tests the solver directly, not the sun capability.

R4 — exact grid hit. A target that lands exactly on a sample returns that instant with residual 0 and zero refinement probes.

R5 — window edge. An event exactly at end is included; an event one millisecond after end is not. The half-open/closed choice is the §4.1 inclusive window.

R6 — budget rejected early. A 40-day window with a 1-minute step needs 57 601 samples; with maxEvaluations: 2048 the call is LIMIT_EXCEEDED before the first read.

R7 — location policy. { kind: 'event', observer: London } on a geocentric profile: the event instant is identical to the birth policy, but the event chart’s cusps are cast for London. On a topocentric profile the same change moves the instant; both instants are reported.

12. Unsupported variants

Variant Why unsupported
lunar, planetary Different body and window; a new bundle (the Moon returns monthly, so a 3-day window is meaningless)
precessed Applies a precession correction to the target longitude; needs its own definition and tolerance
exact-longitude-override A caller-chosen target is not a return to the reference position
sampled (grid-only) Explicitly not a return; a grid point is never an event
Solar-return chart without a location policy No location is assumed
Primary-directions or profection returns Different techniques

13. Prerequisites before W-08

The implementation and coordinator integration now satisfy these runtime seams; convention sources remain the responsibility of each supplied production rulebook. See the package tests.

Id Prerequisite Owner
P0 The feature can reach a ComposerPort to compose the event chart W-11 owner (index)
P2 Probe-session ownership: the engine opens the session with the technique’s declared limits W-11/C-04 owner
P3 A reviewed source for the bisection solver, the tolerances and the inclusive window Return reviewer
P4 returns.solar@1 payload schema and validator registered W-08
P5 A synthetic probe host that evaluates λ(t) from a fixture formula, so R1–R6 need no ephemeris W-08