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. Bounded search
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. maxProbesPerEventis bounded by the remainingProbeBudget.maxEvaluations; whichever is smaller wins. Exhausting either isexhausted, 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
toleranceMillisecondsof each other are the same event and are deduplicated (keep the one with the smaller residual). ambiguousis 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-eventis a successful result. It carries the searched window, the grid step, the probe count and the residual range, so “nothing happened” is auditable.exhaustedcarries the consumed evaluation count and the unresolved bracket; it is distinct fromLIMIT_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 |
birthwith no referencehouseObserverisINVALID_REQUEST; no birth place is invented.currentandeventdiffer 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 |