caelus 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@ ephemeris files. 1:1 port of the Python reference, checked by golden fixtures.
5
5
 
6
6
  ## Verification chain
7
7
 
8
- 1. Python engine checked against Swiss Ephemeris 2.10 across 19002099:
8
+ 1. Python engine checked against Swiss Ephemeris 2.10 across 18502150:
9
9
  every planet ≤ 1″ (Sun–Saturn), Moon ≤ 2.5″, Chiron ≤ 1″, mean node ≤ 1″,
10
10
  true node ≤ 1′ vs SE's built-in ephemeris (≤ 1″ vs JPL DE431)
11
11
  (vs full DE431 files, 1850–2149), angles and Placidus cusps ≤ 3.2″ — all
@@ -100,4 +100,4 @@ test/golden.test.ts conformance suite vs Python fixtures
100
100
  - caelus — this package
101
101
  - [caelus-birth](https://www.npmjs.com/package/caelus-birth) — local birth time + place → UT (charts take UT; use this)
102
102
  - [caelus-wheel](https://www.npmjs.com/package/caelus-wheel) — React SVG chart wheel
103
- - [caelus-mcp](https://www.npmjs.com/package/caelus-mcp) — MCP server, twenty-seven chart tools over stdio
103
+ - [caelus-mcp](https://www.npmjs.com/package/caelus-mcp) — MCP server, twenty-nine chart tools over stdio
package/accuracy.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
- "basis": "Swiss Ephemeris 2.10, 1900-2099, apparent geocentric ecliptic longitude (true equinox of date)",
3
- "range": "1900-2099",
2
+ "basis": "Swiss Ephemeris 2.10, 1850-2150, apparent geocentric ecliptic longitude (true equinox of date)",
3
+ "range": "1850-2150",
4
4
  "unit": "arcsec",
5
5
  "bodies": [
6
6
  {
7
7
  "name": "Sun",
8
- "max": "0.4",
8
+ "max": "0.5",
9
9
  "rms": "0.2",
10
10
  "note": ""
11
11
  },
@@ -23,7 +23,7 @@
23
23
  },
24
24
  {
25
25
  "name": "Mercury",
26
- "max": "0.5",
26
+ "max": "0.6",
27
27
  "rms": "0.2",
28
28
  "note": ""
29
29
  },
@@ -47,7 +47,7 @@
47
47
  },
48
48
  {
49
49
  "name": "Saturn",
50
- "max": "0.8",
50
+ "max": "1.0",
51
51
  "rms": "0.4",
52
52
  "note": ""
53
53
  },
@@ -65,9 +65,9 @@
65
65
  },
66
66
  {
67
67
  "name": "Pluto",
68
- "max": "2.5",
68
+ "max": "3.4",
69
69
  "rms": "1.0",
70
- "note": "series valid 1885-2099"
70
+ "note": "JPL Horizons barycenter Chebyshev pack (1700-2212, fit residual 4.1e-6 AU ≈ 0.03″); supersedes the Meeus ch.37 series. Bound is vs SE's own Moshier Pluto"
71
71
  },
72
72
  {
73
73
  "name": "Chiron",
@@ -101,7 +101,7 @@
101
101
  },
102
102
  {
103
103
  "name": "Mean Lilith",
104
- "max": "1.3",
104
+ "max": "1.6",
105
105
  "rms": "0.5",
106
106
  "note": "mean lunar apogee on the inclined orbit; latitude ≤0.1″"
107
107
  },
@@ -113,13 +113,13 @@
113
113
  },
114
114
  {
115
115
  "name": "RA / Dec",
116
- "max": "2.1",
116
+ "max": "2.6",
117
117
  "rms": "—",
118
118
  "note": "rotation is exact; bound tracks each body's ecliptic accuracy (Moon worst)"
119
119
  },
120
120
  {
121
121
  "name": "Topocentric Moon",
122
- "max": "2.5",
122
+ "max": "2.7",
123
123
  "rms": "—",
124
124
  "note": "parallax model adds ≤0.1″ over the geocentric bound"
125
125
  },
@@ -161,7 +161,7 @@
161
161
  },
162
162
  {
163
163
  "name": "True Lilith (osc. apogee)",
164
- "max": "187",
164
+ "max": "214",
165
165
  "rms": "—",
166
166
  "note": "hypersensitive to the lunar theory (~1/e amplification); SE's own Moshier-vs-DE difference dominates. 'True Lilith' values disagree across software at this scale"
167
167
  },
@@ -221,7 +221,7 @@
221
221
  },
222
222
  {
223
223
  "label": "Pluto / Chiron",
224
- "bound": "≤ 2.5″ / ≤ 1″"
224
+ "bound": "≤ 3.4″ / ≤ 1″"
225
225
  },
226
226
  {
227
227
  "label": "Angles & Placidus cusps",
@@ -233,7 +233,7 @@
233
233
  },
234
234
  {
235
235
  "label": "Mean Lilith",
236
- "bound": "≤ 1.3″"
236
+ "bound": "≤ 1.6″"
237
237
  },
238
238
  {
239
239
  "label": "8 new house systems",
@@ -268,12 +268,12 @@
268
268
  "bound": "types exact; max ≤ 9 s"
269
269
  }
270
270
  ],
271
- "v03_harness": "python/validate_swiss.py regenerates every figure above the line against pyswisseph 2.10 (Moshier mode)",
271
+ "v03_harness": "python/validate_swiss.py regenerates every figure above the line against pyswisseph 2.10 (Moshier mode), sampled over 1850-2150; validate_horizons.py cross-checks the edges (banded 1800/2200) against JPL directly",
272
272
  "true_node_vs_builtin": "1′",
273
273
  "counts": {
274
274
  "house_systems": 12,
275
275
  "sidereal_ayanamsas": 7,
276
- "mcp_tools": 27,
276
+ "mcp_tools": 29,
277
277
  "default_bodies": 13
278
278
  }
279
279
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * astroengine anchored charts -- realize a {@link Realm} + anchors into a chart.
3
+ *
4
+ * This is where the provenance layer meets the two generators. Given what a
5
+ * chart *is* and how its time/place are anchored, {@link realize} routes:
6
+ * when an instant can be resolved it runs the ephemeris (`chartAt`); when it
7
+ * cannot but constraints are supplied it runs the compiler's symbolic synthesis
8
+ * (`compileForm`); otherwise it reports that nothing could be computed and why.
9
+ * The realm rides along as framing for the interpretation layer.
10
+ */
11
+ import { Engine, Chart, ChartOptions } from "./chart.js";
12
+ import { CompiledForm, Constraint } from "./compiler.js";
13
+ import { Realm, TemporalAnchor, SpatialAnchor, AnchorRegistry, ResolvedTime, ResolvedPlace } from "./provenance.js";
14
+ /** A chart described by its realm and anchors, not a bare (instant, place). */
15
+ export interface AnchoredChart {
16
+ realm: Realm;
17
+ when: TemporalAnchor;
18
+ where?: SpatialAnchor;
19
+ /** Constraints for the compiler path -- a chart with no instant
20
+ * (archetypal / conceptual / symbolic). */
21
+ constraints?: Constraint[];
22
+ }
23
+ /** The outcome of {@link realize}: how (or whether) a chart was produced. */
24
+ export interface RealizedChart {
25
+ realm: Realm;
26
+ /** `ephemeris` (an instant resolved), `compiler` (synthesized from
27
+ * constraints), or `none` (neither was possible). */
28
+ via: "ephemeris" | "compiler" | "none";
29
+ time: ResolvedTime;
30
+ place: ResolvedPlace;
31
+ /** The computed chart, when an instant was available. */
32
+ chart: Chart | null;
33
+ /** The synthesized form, when the compiler path ran. */
34
+ form: CompiledForm | null;
35
+ note: string;
36
+ }
37
+ /**
38
+ * Realize an {@link AnchoredChart}: resolve its anchors and run the appropriate
39
+ * generator. A resolvable instant always wins (the realm is then just framing);
40
+ * failing that, constraints synthesize a form; failing both, nothing computes.
41
+ *
42
+ * @param engine The engine for the ephemeris path.
43
+ * @param anchored The realm + temporal/spatial anchors (+ optional constraints).
44
+ * @param registry Lookups for relative/narrative/named anchors.
45
+ * @param opts Chart options (house system, zodiac) for the ephemeris path.
46
+ * @returns A {@link RealizedChart}; `chart`/`form` are null on the paths that
47
+ * did not run.
48
+ */
49
+ export declare function realize(engine: Engine, anchored: AnchoredChart, registry?: AnchorRegistry, opts?: ChartOptions): RealizedChart;
@@ -0,0 +1,40 @@
1
+ import { compileForm } from "./compiler.js";
2
+ import { resolveTime, resolvePlace, isTimeAnchored, } from "./provenance.js";
3
+ /**
4
+ * Realize an {@link AnchoredChart}: resolve its anchors and run the appropriate
5
+ * generator. A resolvable instant always wins (the realm is then just framing);
6
+ * failing that, constraints synthesize a form; failing both, nothing computes.
7
+ *
8
+ * @param engine The engine for the ephemeris path.
9
+ * @param anchored The realm + temporal/spatial anchors (+ optional constraints).
10
+ * @param registry Lookups for relative/narrative/named anchors.
11
+ * @param opts Chart options (house system, zodiac) for the ephemeris path.
12
+ * @returns A {@link RealizedChart}; `chart`/`form` are null on the paths that
13
+ * did not run.
14
+ */
15
+ export function realize(engine, anchored, registry = {}, opts = {}) {
16
+ const time = resolveTime(anchored.when, registry);
17
+ const place = resolvePlace(anchored.where ?? { kind: "none", reason: "intentionally_unset" }, registry);
18
+ const base = { realm: anchored.realm, time, place };
19
+ if (time.jd !== null) {
20
+ const chart = engine.chartAt(time.jd, place.place?.lat ?? 0, place.place?.lonEast ?? 0, opts);
21
+ return {
22
+ ...base, via: "ephemeris", chart, form: null,
23
+ note: `ephemeris (${time.certainty}) `
24
+ + (place.place ? `at ${place.note ?? "given coordinates"}` : "no place; houses nominal at 0,0"),
25
+ };
26
+ }
27
+ if (anchored.constraints?.length) {
28
+ const form = compileForm(anchored.constraints);
29
+ return {
30
+ ...base, via: "compiler", chart: null, form,
31
+ note: `compiler synthesis (${form.impossible ? "impossible form" : `residual ${form.residual.toFixed(2)}`})`,
32
+ };
33
+ }
34
+ return {
35
+ ...base, via: "none", chart: null, form: null,
36
+ note: isTimeAnchored(anchored.realm)
37
+ ? `time-anchored realm but no instant resolved (${time.note ?? "no time"}); supply a resolvable anchor or constraints`
38
+ : `${anchored.realm} realm has no instant; supply constraints to synthesize a form`,
39
+ };
40
+ }
@@ -24,10 +24,15 @@ export function planetLines(ra, dec, gastDeg, latMin = -85.0, latMax = 85.0, lat
24
24
  const asc = [];
25
25
  const dsc = [];
26
26
  const n = Math.floor((latMax - latMin) / latStep + 1e-9); // never exceed latMax
27
+ // Skip the degenerate near-tangent point where |x| -> 1 (h0 -> 0 or 180): the
28
+ // body grazes the horizon at the meridian, acos' is singular there, and
29
+ // cross-platform libm rounding would swing the longitude by ~mas. Trimming it
30
+ // costs <0.003 deg of line length and makes the track deterministic.
31
+ const EDGE = 1.0 - 1e-9;
27
32
  for (let i = 0; i <= n; i++) {
28
33
  const phi = latMin + i * latStep;
29
34
  const x = -Math.tan(phi * DEG) * td;
30
- if (x >= -1.0 && x <= 1.0) {
35
+ if (x >= -EDGE && x <= EDGE) {
31
36
  const h0 = Math.acos(x) / DEG; // hour-angle half-width, degrees
32
37
  asc.push([mapLon(ra - h0 - gastDeg), phi]); // eastern horizon
33
38
  dsc.push([mapLon(ra + h0 - gastDeg), phi]); // western horizon
@@ -0,0 +1,90 @@
1
+ /**
2
+ * astroengine interpretation brief -- a chart as compact, citable LLM input,
3
+ * and an audit that the model cited real facts.
4
+ *
5
+ * This is the "novel and accurate" seam. An LLM writes fluent, original prose
6
+ * (novel); to keep it honest (accurate) it is given only the validated fact
7
+ * atoms, each tagged with a stable id, and asked to cite the id(s) every
8
+ * statement rests on. {@link auditCitations} then checks those citations
9
+ * resolve -- a claim that cites an id not in the brief invented its provenance
10
+ * and is flagged. The chart math was never the model's to hallucinate.
11
+ *
12
+ * Pairs with the MCP app, where the host model is already the interpreter:
13
+ * feed it {@link Brief.prompt} instead of raw positions it would guess at.
14
+ */
15
+ import type { FactKind, InterpretationContext } from "./interpretation.js";
16
+ import type { Reading } from "./interpret.js";
17
+ import type { Zodiac } from "./chart.js";
18
+ import type { Realm, Certainty } from "./provenance.js";
19
+ /** Default instruction header prepended to {@link Brief.prompt}. */
20
+ export declare const BRIEF_INSTRUCTIONS: string;
21
+ /** A one-line framing for the realm and certainty, or `""` when neither needs it. */
22
+ export declare function realmFraming(realm?: Realm, certainty?: Certainty): string;
23
+ export interface BriefOptions {
24
+ /** Keep only the top-N facts by salience. Default: all. */
25
+ limit?: number;
26
+ /** Restrict to certain atom kinds. */
27
+ kinds?: FactKind[];
28
+ /** Drop facts below this salience. */
29
+ minSalience?: number;
30
+ /** Fold a resolved {@link Reading}'s entries in as suggested readings. */
31
+ reading?: Reading;
32
+ /** Prepend {@link BRIEF_INSTRUCTIONS}. Default `true`. */
33
+ header?: boolean;
34
+ }
35
+ /** A salience-ranked fact in a {@link Brief}. */
36
+ export interface BriefFact {
37
+ id: string;
38
+ kind: FactKind;
39
+ text: string;
40
+ salience: number;
41
+ }
42
+ /** A chart rendered as citable LLM input. */
43
+ export interface Brief {
44
+ jdUt: number;
45
+ zodiac: Zodiac;
46
+ /** The facts offered, ranked by salience. */
47
+ facts: BriefFact[];
48
+ /** A prompt-ready, id-tagged rendering of {@link Brief.facts}. */
49
+ prompt: string;
50
+ }
51
+ /**
52
+ * Render an {@link InterpretationContext} as a compact, id-tagged {@link Brief}
53
+ * for an LLM to interpret and cite.
54
+ *
55
+ * @param ctx A projection from {@link interpretationContext}.
56
+ * @param opts Capping, kind filter, an optional {@link Reading} to fold in, and
57
+ * whether to prepend {@link BRIEF_INSTRUCTIONS}.
58
+ * @returns The {@link Brief}: a ranked `facts` list and a ready `prompt`.
59
+ */
60
+ export declare function chartBrief(ctx: InterpretationContext, opts?: BriefOptions): Brief;
61
+ /** A model-produced statement and the fact ids it claims to rest on. */
62
+ export interface Claim {
63
+ text: string;
64
+ cites: string[];
65
+ }
66
+ /** The result of {@link auditCitations}. */
67
+ export interface CitationAudit {
68
+ /** True when every cited id resolves to a fact in the context. */
69
+ ok: boolean;
70
+ /** Total claims examined. */
71
+ claims: number;
72
+ /** Claims that cited at least one fact. */
73
+ cited: number;
74
+ /** Claims with no citation. */
75
+ uncited: number;
76
+ /** Distinct cited ids that resolve to a real atom. */
77
+ valid: string[];
78
+ /** Distinct cited ids with no matching atom -- invented provenance. */
79
+ unknown: string[];
80
+ }
81
+ /**
82
+ * Check that a model's claims cite only facts that exist in the context -- the
83
+ * accuracy half of "novel and accurate". A claim citing an id not in `ctx`
84
+ * fabricated its provenance, so `ok` is false and the id lands in `unknown`.
85
+ *
86
+ * @param claims The model's statements with their cited ids.
87
+ * @param ctx The {@link InterpretationContext} the brief was built from.
88
+ * @returns A {@link CitationAudit}.
89
+ */
90
+ export declare function auditCitations(claims: Claim[], ctx: InterpretationContext): CitationAudit;
@@ -0,0 +1,85 @@
1
+ /** Default instruction header prepended to {@link Brief.prompt}. */
2
+ export const BRIEF_INSTRUCTIONS = "Natal chart facts follow, each with a stable id in [brackets]. Interpret them "
3
+ + "in your own words; after each statement, cite the id(s) it rests on as [id]. "
4
+ + "Do not introduce astrological facts that are not listed here.";
5
+ /** How to frame an interpretation given what the chart is. */
6
+ const REALM_FRAMING = {
7
+ observed: "", reported: "",
8
+ planned: "This is a planned future moment; frame statements as potentials, not settled facts.",
9
+ forecast: "This is a forecast moment; frame statements as tendencies, not certainties.",
10
+ fictional: "This is a fictional subject; interpret the symbolism, not a real person's life.",
11
+ mythic: "This is a mythic subject; read it as a symbol or story, not a biography.",
12
+ counterfactual: "This is a hypothetical variant of a real event; keep it conditional.",
13
+ archetypal: "This is an archetype, not a person; interpret the configuration's meaning itself.",
14
+ conceptual: "This is a concept or organization, not a person; interpret it as such.",
15
+ };
16
+ /** A one-line framing for the realm and certainty, or `""` when neither needs it. */
17
+ export function realmFraming(realm, certainty) {
18
+ const parts = [];
19
+ if (realm && REALM_FRAMING[realm])
20
+ parts.push(REALM_FRAMING[realm]);
21
+ if (certainty && certainty !== "exact") {
22
+ parts.push(`The time is ${certainty}, so the Moon, the angles, and the houses are uncertain`
23
+ + " -- lean on the slower planets and sign-level statements.");
24
+ }
25
+ return parts.join(" ");
26
+ }
27
+ /**
28
+ * Render an {@link InterpretationContext} as a compact, id-tagged {@link Brief}
29
+ * for an LLM to interpret and cite.
30
+ *
31
+ * @param ctx A projection from {@link interpretationContext}.
32
+ * @param opts Capping, kind filter, an optional {@link Reading} to fold in, and
33
+ * whether to prepend {@link BRIEF_INSTRUCTIONS}.
34
+ * @returns The {@link Brief}: a ranked `facts` list and a ready `prompt`.
35
+ */
36
+ export function chartBrief(ctx, opts = {}) {
37
+ let atoms = ctx.atoms;
38
+ if (opts.kinds)
39
+ atoms = atoms.filter((a) => opts.kinds.includes(a.kind));
40
+ if (opts.minSalience !== undefined)
41
+ atoms = atoms.filter((a) => a.salience >= opts.minSalience);
42
+ if (opts.limit !== undefined)
43
+ atoms = atoms.slice(0, opts.limit);
44
+ const facts = atoms.map((a) => ({
45
+ id: a.id, kind: a.kind, text: a.text, salience: a.salience,
46
+ }));
47
+ const lines = facts.map((f) => `[${f.id}] ${f.text}`);
48
+ const framing = realmFraming(ctx.realm, ctx.certainty);
49
+ let prompt = (opts.header === false ? "" : `${BRIEF_INSTRUCTIONS}\n\n`)
50
+ + (framing ? `${framing}\n\n` : "")
51
+ + lines.join("\n");
52
+ if (opts.reading && opts.reading.entries.length) {
53
+ prompt += "\n\nSuggested readings (cite the same fact ids):\n"
54
+ + opts.reading.entries
55
+ .map((e) => `${e.atomIds.map((id) => `[${id}]`).join("")} ${e.text}`)
56
+ .join("\n");
57
+ }
58
+ return { jdUt: ctx.jdUt, zodiac: ctx.zodiac, facts, prompt };
59
+ }
60
+ /**
61
+ * Check that a model's claims cite only facts that exist in the context -- the
62
+ * accuracy half of "novel and accurate". A claim citing an id not in `ctx`
63
+ * fabricated its provenance, so `ok` is false and the id lands in `unknown`.
64
+ *
65
+ * @param claims The model's statements with their cited ids.
66
+ * @param ctx The {@link InterpretationContext} the brief was built from.
67
+ * @returns A {@link CitationAudit}.
68
+ */
69
+ export function auditCitations(claims, ctx) {
70
+ const ids = new Set(ctx.atoms.map((a) => a.id));
71
+ const valid = new Set();
72
+ const unknown = new Set();
73
+ let cited = 0;
74
+ for (const c of claims) {
75
+ if (c.cites.length)
76
+ cited++;
77
+ for (const id of c.cites)
78
+ (ids.has(id) ? valid : unknown).add(id);
79
+ }
80
+ return {
81
+ ok: unknown.size === 0,
82
+ claims: claims.length, cited, uncited: claims.length - cited,
83
+ valid: [...valid], unknown: [...unknown],
84
+ };
85
+ }
@@ -1,5 +1,6 @@
1
1
  /** astroengine chart -- public API: natal charts, aspects, retrogrades. */
2
2
  import { EngineData, AYANAMSA_J2000 } from "./core.js";
3
+ import type { AspectPhase } from "./electional.js";
3
4
  export declare const BODIES: readonly ["sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune", "pluto", "chiron", "mean_node", "true_node"];
4
5
  export type Body = (typeof BODIES)[number];
5
6
  /** Computable on request (not in the default chart set). */
@@ -130,6 +131,10 @@ export interface Aspect {
130
131
  aspect: string;
131
132
  /** Orb from exact, in degrees. */
132
133
  orb: number;
134
+ /** Applying, separating, or exact -- from the two bodies' longitude speeds. */
135
+ phase: AspectPhase;
136
+ /** Closeness in `[0, 1]`: `1` exact, `0` at the orb limit. */
137
+ strength: number;
133
138
  }
134
139
  /** A full natal chart, as returned by {@link Engine.chart} and
135
140
  * {@link Engine.chartAt}. Longitudes are degrees in the chart's `zodiac`. */
package/dist/src/chart.js CHANGED
@@ -200,7 +200,10 @@ export class Engine {
200
200
  : moonApparentSeries(this.data, jde);
201
201
  return [lon, lat, km / KM_PER_AU];
202
202
  }
203
- if (body === "pluto")
203
+ // Pluto: a wide-range Chebyshev pack when one is loaded (same heliocentric
204
+ // pipeline as Chiron, via the generic packed-body path below), else the
205
+ // Meeus ch.37 series (valid 1885-2099, accuracy degrades outside).
206
+ if (body === "pluto" && !this.data.chebPacks?.pluto)
204
207
  return plutoApparent(this.data, jde);
205
208
  if (body === "chiron") {
206
209
  if (!this.chironCheb)
@@ -346,7 +349,7 @@ export class Engine {
346
349
  let l;
347
350
  let b;
348
351
  let r;
349
- if (body === "pluto") {
352
+ if (body === "pluto" && !this.data.chebPacks?.pluto) {
350
353
  [l, b, r] = plutoHeliocentric(this.data, jde);
351
354
  [l, b] = precessEcliptic(l, b, J2000, jde);
352
355
  }
@@ -628,11 +631,24 @@ export function findAspects(bodies, orbs = DEFAULT_ORBS) {
628
631
  for (let j = i + 1; j < names.length; j++) {
629
632
  const a = names[i];
630
633
  const b = names[j];
631
- const sep = Math.abs(mod(bodies[a].lon - bodies[b].lon + 180, 360) - 180);
634
+ const e = mod(bodies[a].lon - bodies[b].lon + 180, 360) - 180; // signed gap
635
+ const sep = Math.abs(e);
632
636
  for (const [asp, angle] of Object.entries(ASPECTS)) {
633
637
  const orb = Math.abs(sep - angle);
634
638
  if (orb <= orbs[asp]) {
635
- out.push({ a, b, aspect: asp, orb: Math.round(orb * 100) / 100 });
639
+ const orbRounded = Math.round(orb * 100) / 100;
640
+ // Applying/separating from the closing of the signed orb (matches
641
+ // electional.aspectPhase); strength from the same rounded orb so a
642
+ // consumer can reproduce it from .orb and the orb policy.
643
+ const signedOrb = sep - angle;
644
+ const dAbsOrbDt = (signedOrb >= 0 ? 1 : -1) * (e >= 0 ? 1 : -1)
645
+ * (bodies[a].speed - bodies[b].speed);
646
+ const phase = Math.abs(signedOrb) < 1e-9
647
+ ? "exact" : dAbsOrbDt < 0 ? "applying" : "separating";
648
+ out.push({
649
+ a, b, aspect: asp, orb: orbRounded,
650
+ phase, strength: Math.max(0, 1 - orbRounded / orbs[asp]),
651
+ });
636
652
  }
637
653
  }
638
654
  }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * astroengine counterfactual -- a real chart, perturbed, and what changed.
3
+ *
4
+ * The `counterfactual` realm: take a resolved chart and ask "what if". Shift the
5
+ * instant ("born an hour later") or the place -- a real ephemeris recompute --
6
+ * or splice a body to a new longitude ("Mars in the next sign") -- a geometry
7
+ * what-if that keeps everything else and recomputes the aspects it touches.
8
+ * {@link chartDiff} reports the difference so the change is legible, not buried
9
+ * in two full charts.
10
+ */
11
+ import { Engine, Chart, ChartOptions, Aspect } from "./chart.js";
12
+ import { AnchorRegistry } from "./provenance.js";
13
+ import { AnchoredChart, RealizedChart } from "./anchored.js";
14
+ /** A perturbation of a resolved chart. */
15
+ export interface CounterfactualEdit {
16
+ /** Shift the resolved instant by a duration (e.g. `"1h"`, `"-30m"`, `"P1D"`). */
17
+ shiftTime?: string;
18
+ /** Recompute at a different place. */
19
+ place?: {
20
+ lat: number;
21
+ lonEast: number;
22
+ altM?: number;
23
+ };
24
+ /** Move bodies to given ecliptic longitudes (degrees), keeping everything else
25
+ * -- a geometry what-if. The moved body's house and the touched aspects are
26
+ * recomputed; the angles and other bodies are untouched. */
27
+ setLongitudes?: Record<string, number>;
28
+ }
29
+ /** A body whose sign or house changed between two charts. */
30
+ export interface BodyChange {
31
+ body: string;
32
+ /** Signed degrees the body moved (`b` minus `a`). */
33
+ dLon: number;
34
+ signFrom: string;
35
+ signTo: string;
36
+ houseFrom: number;
37
+ houseTo: number;
38
+ }
39
+ /** An angle whose sign changed. */
40
+ export interface AngleChange {
41
+ angle: string;
42
+ from: string;
43
+ to: string;
44
+ }
45
+ /** What differs between two charts. */
46
+ export interface ChartDiff {
47
+ /** Bodies whose sign or house changed. */
48
+ bodies: BodyChange[];
49
+ /** Aspects present in the variant but not the original. */
50
+ aspectsGained: Aspect[];
51
+ /** Aspects present in the original but not the variant. */
52
+ aspectsLost: Aspect[];
53
+ /** Angles whose sign changed. */
54
+ angles: AngleChange[];
55
+ }
56
+ /** Diff two charts: body sign/house shifts, aspects gained/lost, angle sign
57
+ * changes. Bodies and angles that did not change sign/house are omitted. */
58
+ export declare function chartDiff(a: Chart, b: Chart): ChartDiff;
59
+ /** A counterfactual: a base chart and a perturbed variant, with the diff. */
60
+ export interface Counterfactual {
61
+ edit: CounterfactualEdit;
62
+ /** The realized base (its `chart` is the original). */
63
+ original: RealizedChart;
64
+ /** The perturbed chart, or null when the base had no chart to perturb. */
65
+ variant: Chart | null;
66
+ diff: ChartDiff | null;
67
+ note: string;
68
+ }
69
+ /**
70
+ * Realize an {@link AnchoredChart}, then apply a {@link CounterfactualEdit} and
71
+ * diff the result -- "a real event, perturbed." A time/place edit recomputes the
72
+ * ephemeris; a `setLongitudes` edit splices the geometry.
73
+ *
74
+ * @param engine The engine.
75
+ * @param base The base chart to perturb (realized via {@link realize}).
76
+ * @param edit The perturbation.
77
+ * @param registry Anchor lookups for the base.
78
+ * @param opts Chart options (house system, zodiac).
79
+ * @returns A {@link Counterfactual}; `variant`/`diff` are null when the base
80
+ * produced no chart (e.g. a constraints-only form).
81
+ */
82
+ export declare function counterfactual(engine: Engine, base: AnchoredChart, edit: CounterfactualEdit, registry?: AnchorRegistry, opts?: ChartOptions): Counterfactual;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * astroengine counterfactual -- a real chart, perturbed, and what changed.
3
+ *
4
+ * The `counterfactual` realm: take a resolved chart and ask "what if". Shift the
5
+ * instant ("born an hour later") or the place -- a real ephemeris recompute --
6
+ * or splice a body to a new longitude ("Mars in the next sign") -- a geometry
7
+ * what-if that keeps everything else and recomputes the aspects it touches.
8
+ * {@link chartDiff} reports the difference so the change is legible, not buried
9
+ * in two full charts.
10
+ */
11
+ import { SIGNS, findAspects, DEFAULT_ORBS, } from "./chart.js";
12
+ import { houseOf } from "./electional.js";
13
+ import { mod } from "./core.js";
14
+ import { parseOffset } from "./provenance.js";
15
+ import { realize } from "./anchored.js";
16
+ const signOf = (lon) => SIGNS[Math.floor(mod(lon, 360) / 30)];
17
+ const aspectKey = (x) => `${[x.a, x.b].sort().join("~")}:${x.aspect}`;
18
+ /** Diff two charts: body sign/house shifts, aspects gained/lost, angle sign
19
+ * changes. Bodies and angles that did not change sign/house are omitted. */
20
+ export function chartDiff(a, b) {
21
+ const bodies = [];
22
+ for (const [name, pa] of Object.entries(a.bodies)) {
23
+ const pb = b.bodies[name];
24
+ if (!pa || !pb || (pa.sign === pb.sign && pa.house === pb.house))
25
+ continue;
26
+ bodies.push({
27
+ body: name, dLon: mod(pb.lon - pa.lon + 180, 360) - 180,
28
+ signFrom: pa.sign, signTo: pb.sign, houseFrom: pa.house, houseTo: pb.house,
29
+ });
30
+ }
31
+ const aset = new Set(a.aspects.map(aspectKey));
32
+ const bset = new Set(b.aspects.map(aspectKey));
33
+ const angles = [];
34
+ for (const ang of ["asc", "mc", "vertex", "eastPoint"]) {
35
+ const from = signOf(a.angles[ang]);
36
+ const to = signOf(b.angles[ang]);
37
+ if (from !== to)
38
+ angles.push({ angle: ang, from, to });
39
+ }
40
+ return {
41
+ bodies,
42
+ aspectsGained: b.aspects.filter((x) => !aset.has(aspectKey(x))),
43
+ aspectsLost: a.aspects.filter((x) => !bset.has(aspectKey(x))),
44
+ angles,
45
+ };
46
+ }
47
+ /** Splice bodies to new longitudes, recomputing their sign/house and the
48
+ * aspects (the angles and untouched bodies stay as they were). */
49
+ function spliceLongitudes(chart, overrides) {
50
+ const bodies = { ...chart.bodies };
51
+ for (const [name, lon] of Object.entries(overrides)) {
52
+ const cur = bodies[name];
53
+ if (!cur)
54
+ continue;
55
+ const L = mod(lon, 360);
56
+ bodies[name] = {
57
+ ...cur, lon: L, sign: SIGNS[Math.floor(L / 30)], signDeg: L % 30,
58
+ house: houseOf(L, chart.cusps),
59
+ };
60
+ }
61
+ return {
62
+ ...chart, bodies: bodies,
63
+ aspects: findAspects(bodies, DEFAULT_ORBS),
64
+ };
65
+ }
66
+ /**
67
+ * Realize an {@link AnchoredChart}, then apply a {@link CounterfactualEdit} and
68
+ * diff the result -- "a real event, perturbed." A time/place edit recomputes the
69
+ * ephemeris; a `setLongitudes` edit splices the geometry.
70
+ *
71
+ * @param engine The engine.
72
+ * @param base The base chart to perturb (realized via {@link realize}).
73
+ * @param edit The perturbation.
74
+ * @param registry Anchor lookups for the base.
75
+ * @param opts Chart options (house system, zodiac).
76
+ * @returns A {@link Counterfactual}; `variant`/`diff` are null when the base
77
+ * produced no chart (e.g. a constraints-only form).
78
+ */
79
+ export function counterfactual(engine, base, edit, registry = {}, opts = {}) {
80
+ const original = realize(engine, base, registry, opts);
81
+ if (!original.chart) {
82
+ return { edit, original, variant: null, diff: null, note: `nothing to perturb (${original.note})` };
83
+ }
84
+ const shiftsTimeOrPlace = edit.shiftTime !== undefined || edit.place !== undefined;
85
+ let variant = original.chart;
86
+ if (shiftsTimeOrPlace) {
87
+ const off = edit.shiftTime !== undefined ? parseOffset(edit.shiftTime) : 0;
88
+ if (Number.isNaN(off))
89
+ throw new Error(`unparseable shiftTime ${edit.shiftTime}`);
90
+ const lat = edit.place?.lat ?? original.place.place?.lat ?? 0;
91
+ const lon = edit.place?.lonEast ?? original.place.place?.lonEast ?? 0;
92
+ variant = engine.chartAt(original.time.jd + off, lat, lon, opts);
93
+ }
94
+ if (edit.setLongitudes)
95
+ variant = spliceLongitudes(variant, edit.setLongitudes);
96
+ const bits = [
97
+ edit.shiftTime !== undefined ? `time ${edit.shiftTime}` : null,
98
+ edit.place ? "place" : null,
99
+ edit.setLongitudes ? `moved ${Object.keys(edit.setLongitudes).join(", ")}` : null,
100
+ ].filter(Boolean);
101
+ return {
102
+ edit, original, variant, diff: chartDiff(original.chart, variant),
103
+ note: bits.length ? `perturbed: ${bits.join("; ")}` : "no edit applied",
104
+ };
105
+ }