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.
@@ -0,0 +1,179 @@
1
+ const hit = (atoms) => ({ matched: atoms.length > 0, atoms });
2
+ // ------------------------------------------------------------- atom selectors
3
+ /** Matches placement atoms by any subset of body / sign / house / retrograde /
4
+ * a held dignity. */
5
+ export function hasPlacement(filter = {}) {
6
+ return (ctx) => hit(ctx.atoms.filter((a) => a.kind === "placement"
7
+ && (filter.body === undefined || a.body === filter.body)
8
+ && (filter.sign === undefined || a.sign === filter.sign)
9
+ && (filter.house === undefined || a.house === filter.house)
10
+ && (filter.retrograde === undefined || a.retrograde === filter.retrograde)
11
+ && (filter.dignity === undefined || a.dignities.includes(filter.dignity))));
12
+ }
13
+ /** Matches aspect atoms. `between` is an unordered pair; `minStrength` filters
14
+ * loose aspects; `phase` filters applying/separating. */
15
+ export function hasAspect(filter = {}) {
16
+ const pair = filter.between ? [...filter.between].sort() : null;
17
+ return (ctx) => hit(ctx.atoms.filter((at) => {
18
+ if (at.kind !== "aspect")
19
+ return false;
20
+ if (filter.a !== undefined && at.a !== filter.a)
21
+ return false;
22
+ if (filter.b !== undefined && at.b !== filter.b)
23
+ return false;
24
+ if (pair && [at.a, at.b].sort().join() !== pair.join())
25
+ return false;
26
+ if (filter.aspect !== undefined && at.aspect !== filter.aspect)
27
+ return false;
28
+ if (filter.phase !== undefined && at.phase !== filter.phase)
29
+ return false;
30
+ if (filter.minStrength !== undefined && at.strength < filter.minStrength)
31
+ return false;
32
+ return true;
33
+ }));
34
+ }
35
+ /** Matches configuration atoms by kind and/or a participating body. */
36
+ export function hasPattern(filter = {}) {
37
+ return (ctx) => hit(ctx.atoms.filter((a) => a.kind === "pattern"
38
+ && (filter.kind === undefined || a.pattern === filter.kind)
39
+ && (filter.body === undefined || a.bodies.includes(filter.body))));
40
+ }
41
+ /** Matches a structural-signature facet, e.g. `("element", "fire")`. */
42
+ export function hasSignature(facet, value) {
43
+ return (ctx) => hit(ctx.atoms.filter((a) => a.kind === "signature" && a.facet === facet
44
+ && (value === undefined || a.value === value)));
45
+ }
46
+ /** Matches an angle atom by which angle and/or its sign. */
47
+ export function hasAngle(angle, sign) {
48
+ return (ctx) => hit(ctx.atoms.filter((a) => a.kind === "angle" && a.angle === angle
49
+ && (sign === undefined || a.sign === sign)));
50
+ }
51
+ /** Matches dispositor atoms by body, its dispositor, and/or the final flag
52
+ * (a body in its own domicile that terminates a dispositor chain). */
53
+ export function hasDispositor(filter = {}) {
54
+ return (ctx) => hit(ctx.atoms.filter((a) => a.kind === "dispositor"
55
+ && (filter.body === undefined || a.body === filter.body)
56
+ && (filter.dispositor === undefined || a.dispositor === filter.dispositor)
57
+ && (filter.final === undefined || a.final === filter.final)));
58
+ }
59
+ /** Matches a mutual reception, optionally involving a given body. */
60
+ export function hasReception(filter = {}) {
61
+ return (ctx) => hit(ctx.atoms.filter((a) => a.kind === "reception"
62
+ && (filter.body === undefined || a.bodies.includes(filter.body))));
63
+ }
64
+ // ----------------------------------------------------------------- combinators
65
+ /** Matches only when every selector matches; returns the union of their atoms. */
66
+ export function matchAll(...sels) {
67
+ return (ctx) => {
68
+ const parts = sels.map((s) => s(ctx));
69
+ return parts.every((p) => p.matched)
70
+ ? { matched: true, atoms: dedupe(parts.flatMap((p) => p.atoms)) }
71
+ : { matched: false, atoms: [] };
72
+ };
73
+ }
74
+ /** Matches when any selector matches; returns the atoms from those that did. */
75
+ export function matchAny(...sels) {
76
+ return (ctx) => {
77
+ const matched = sels.map((s) => s(ctx)).filter((p) => p.matched);
78
+ return matched.length
79
+ ? { matched: true, atoms: dedupe(matched.flatMap((p) => p.atoms)) }
80
+ : { matched: false, atoms: [] };
81
+ };
82
+ }
83
+ /** Matches when the selector does NOT match (an absence test); no atoms. */
84
+ export function matchNone(sel) {
85
+ return (ctx) => ({ matched: !sel(ctx).matched, atoms: [] });
86
+ }
87
+ function dedupe(atoms) {
88
+ const seen = new Set();
89
+ return atoms.filter((a) => (seen.has(a.id) ? false : seen.add(a.id)));
90
+ }
91
+ /**
92
+ * Run interpretation sources against a fact projection and return a ranked
93
+ * {@link Reading}. Each rule whose selector matches emits an entry carrying the
94
+ * matched atom ids (provenance) and a salience = sum of those atoms' salience x
95
+ * the rule weight. The engine never ships the content: the sources are the
96
+ * caller's.
97
+ *
98
+ * @param ctx A projection from {@link interpretationContext}.
99
+ * @param sources One or more {@link InterpretationSource} corpora.
100
+ * @returns The {@link Reading}; entries are sorted by descending salience.
101
+ */
102
+ export function interpret(ctx, sources) {
103
+ const entries = [];
104
+ for (const src of sources) {
105
+ for (const rule of src.rules) {
106
+ const m = rule.when(ctx);
107
+ if (!m.matched)
108
+ continue;
109
+ const text = typeof rule.text === "function" ? rule.text(m, ctx) : rule.text;
110
+ const salience = m.atoms.reduce((s, a) => s + a.salience, 0) * (rule.weight ?? 1);
111
+ entries.push({
112
+ id: `${src.id}/${rule.id}`, source: src.id, rule: rule.id, text,
113
+ atomIds: m.atoms.map((a) => a.id), salience, tags: rule.tags,
114
+ });
115
+ }
116
+ }
117
+ entries.sort((p, q) => q.salience - p.salience || (p.id < q.id ? -1 : 1));
118
+ return { jdUt: ctx.jdUt, entries };
119
+ }
120
+ /**
121
+ * Group a {@link Reading}'s entries by the facts they share, so statements about
122
+ * the same atoms surface together rather than scattered through a flat list --
123
+ * the substrate for "everything said about this placement" and for spotting
124
+ * contention. Entries are connected when their cited atoms overlap; an entry
125
+ * citing nothing (an absence rule) stands alone. A group is `contested` when a
126
+ * declared conflicting tag-pair both appear in it.
127
+ *
128
+ * Semantic contradiction is the corpus author's to declare (via `tags` +
129
+ * `conflicts`); the resolver does the bookkeeping, not the judgement.
130
+ *
131
+ * @param reading A reading from {@link interpret}.
132
+ * @param opts Conflicting tag pairs and optional text de-duplication.
133
+ * @returns Groups sorted by descending salience.
134
+ */
135
+ export function reconcile(reading, opts = {}) {
136
+ let entries = reading.entries;
137
+ if (opts.dedupe) {
138
+ const seen = new Set(); // entries arrive salience-sorted: keep first
139
+ entries = entries.filter((e) => (seen.has(e.text) ? false : seen.add(e.text)));
140
+ }
141
+ // Union-find over entries that share an atom id.
142
+ const parent = entries.map((_, i) => i);
143
+ const find = (x) => {
144
+ while (parent[x] !== x) {
145
+ parent[x] = parent[parent[x]];
146
+ x = parent[x];
147
+ }
148
+ return x;
149
+ };
150
+ const firstByAtom = new Map();
151
+ entries.forEach((e, i) => {
152
+ for (const id of e.atomIds) {
153
+ const seen = firstByAtom.get(id);
154
+ if (seen === undefined)
155
+ firstByAtom.set(id, i);
156
+ else
157
+ parent[find(i)] = find(seen);
158
+ }
159
+ });
160
+ const buckets = new Map();
161
+ entries.forEach((e, i) => {
162
+ const r = find(i);
163
+ (buckets.get(r) ?? buckets.set(r, []).get(r)).push(e);
164
+ });
165
+ const conflicts = opts.conflicts ?? [];
166
+ const groups = [...buckets.values()].map((es) => {
167
+ es.sort((a, b) => b.salience - a.salience || (a.id < b.id ? -1 : 1));
168
+ const tags = [...new Set(es.flatMap((e) => e.tags ?? []))];
169
+ return {
170
+ atomIds: [...new Set(es.flatMap((e) => e.atomIds))],
171
+ entries: es, tags,
172
+ contested: conflicts.some(([x, y]) => tags.includes(x) && tags.includes(y)),
173
+ salience: Math.max(...es.map((e) => e.salience)),
174
+ };
175
+ });
176
+ groups.sort((a, b) => b.salience - a.salience
177
+ || (a.atomIds.join() < b.atomIds.join() ? -1 : 1));
178
+ return groups;
179
+ }
@@ -0,0 +1,137 @@
1
+ import type { Chart, Zodiac } from "./chart.js";
2
+ import type { AspectPhase } from "./electional.js";
3
+ import { ChartPattern } from "./patterns.js";
4
+ import { ChartSignature } from "./signature.js";
5
+ import type { Realm, Certainty } from "./provenance.js";
6
+ /** Atom kinds in an {@link InterpretationContext}. */
7
+ export type FactKind = "placement" | "aspect" | "pattern" | "signature" | "angle" | "dispositor" | "reception";
8
+ interface FactAtomBase {
9
+ /** Stable, content-addressable id, e.g. `"placement:mars"` or
10
+ * `"aspect:mars~saturn:square"`. Interpretations cite this. */
11
+ id: string;
12
+ kind: FactKind;
13
+ /** Body ids this atom concerns (empty for body-less signature facets). */
14
+ bodies: string[];
15
+ /** Transparent salience (higher = more prominent); see {@link SalienceWeights}. */
16
+ salience: number;
17
+ /** Plain-language statement of the fact -- no interpretation. */
18
+ text: string;
19
+ }
20
+ export interface PlacementAtom extends FactAtomBase {
21
+ kind: "placement";
22
+ body: string;
23
+ sign: string;
24
+ signDeg: number;
25
+ house: number;
26
+ retrograde: boolean;
27
+ dignities: string[];
28
+ }
29
+ export interface AspectAtom extends FactAtomBase {
30
+ kind: "aspect";
31
+ a: string;
32
+ b: string;
33
+ aspect: string;
34
+ /** Orb from exact, degrees. */
35
+ orb: number;
36
+ /** Applying, separating, or exact -- from the two bodies' speeds. */
37
+ phase: AspectPhase;
38
+ /** Closeness in `[0, 1]`: `1` exact, `0` at the orb limit. */
39
+ strength: number;
40
+ }
41
+ export interface PatternAtom extends FactAtomBase {
42
+ kind: "pattern";
43
+ /** Configuration kind, e.g. `"t_square"`, `"grand_trine"`. */
44
+ pattern: string;
45
+ /** Focal body for a T-square or yod. */
46
+ apex?: string;
47
+ }
48
+ export interface SignatureAtom extends FactAtomBase {
49
+ kind: "signature";
50
+ /** Which facet of the structural signature this states. */
51
+ facet: "element" | "modality" | "sign" | "ruler";
52
+ value: string;
53
+ }
54
+ export interface AngleAtom extends FactAtomBase {
55
+ kind: "angle";
56
+ angle: "asc" | "mc" | "vertex" | "eastPoint";
57
+ sign: string;
58
+ signDeg: number;
59
+ }
60
+ export interface DispositorAtom extends FactAtomBase {
61
+ kind: "dispositor";
62
+ body: string;
63
+ /** The classical ruler of the body's sign (equals `body` when in domicile). */
64
+ dispositor: string;
65
+ /** The body occupies its own domicile -- a chain terminus / final dispositor. */
66
+ final: boolean;
67
+ }
68
+ export interface ReceptionAtom extends FactAtomBase {
69
+ kind: "reception";
70
+ /** The dignities the reception runs through: a single dignity when both
71
+ * bodies receive by the same (`"domicile"`, `"exaltation"`, `"triplicity"`),
72
+ * else a sorted pair for a mixed reception (e.g. `"domicile-exaltation"`). */
73
+ by: string;
74
+ }
75
+ export type FactAtom = PlacementAtom | AspectAtom | PatternAtom | SignatureAtom | AngleAtom | DispositorAtom | ReceptionAtom;
76
+ /** A chart as a flat, ranked list of {@link FactAtom}s. */
77
+ export interface InterpretationContext {
78
+ jdUt: number;
79
+ zodiac: Zodiac;
80
+ /** Atoms sorted by descending {@link FactAtomBase.salience}, then `id`. */
81
+ atoms: FactAtom[];
82
+ /** What the chart is, when supplied via {@link ContextOptions.provenance} --
83
+ * framing for an interpreter (a forecast is provisional, a mythic chart is a
84
+ * symbol, not a biography). */
85
+ realm?: Realm;
86
+ /** How firmly the instant is known. When not `"exact"`, time-sensitive atoms
87
+ * (the Moon, the angles) are damped, since their positions are less certain. */
88
+ certainty?: Certainty;
89
+ }
90
+ /** Additive salience weights. Each contribution is documented at its use site;
91
+ * override any subset through {@link ContextOptions.salience}. */
92
+ export interface SalienceWeights {
93
+ /** Every atom starts here. */
94
+ base: number;
95
+ /** Added when the Sun or Moon is involved. */
96
+ luminary: number;
97
+ /** Added for an angular house (1/4/7/10) or an angle atom. */
98
+ angular: number;
99
+ /** Added to the placement of the Ascendant ruler. */
100
+ chartRuler: number;
101
+ /** Added per essential dignity a body holds. */
102
+ dignity: number;
103
+ /** Added to a hard aspect (conjunction/square/opposition). */
104
+ hardAspect: number;
105
+ /** Base salience of a whole configuration (T-square, grand trine, ...). */
106
+ pattern: number;
107
+ /** Added to a dispositor link (and again when it is a final dispositor). */
108
+ dispositor: number;
109
+ /** Added to a mutual reception. */
110
+ reception: number;
111
+ }
112
+ export declare const DEFAULT_SALIENCE: SalienceWeights;
113
+ export interface ContextOptions {
114
+ /** Salience weights to override (merged over {@link DEFAULT_SALIENCE}). */
115
+ salience?: Partial<SalienceWeights>;
116
+ /** Precomputed patterns/signature, to avoid recomputing them. */
117
+ patterns?: ChartPattern[];
118
+ signature?: ChartSignature;
119
+ /** The chart's grounding. Carried onto the context; an inexact `certainty`
120
+ * damps time-sensitive atoms. Wire from {@link realize}'s result. */
121
+ provenance?: {
122
+ realm?: Realm;
123
+ certainty?: Certainty;
124
+ };
125
+ }
126
+ /**
127
+ * Project a {@link Chart} into a ranked list of {@link FactAtom}s -- the
128
+ * substrate an interpretation layer consumes. Pure and deterministic; computes
129
+ * applying/separating and a normalized strength for each aspect that the bare
130
+ * {@link Chart.aspects} list omits.
131
+ *
132
+ * @param chart A chart from {@link Engine.chart} / {@link Engine.chartAt}.
133
+ * @param opts Salience overrides, orb policy, and precomputed reductions.
134
+ * @returns The {@link InterpretationContext}; `atoms` are sorted by salience.
135
+ */
136
+ export declare function interpretationContext(chart: Chart, opts?: ContextOptions): InterpretationContext;
137
+ export {};
@@ -0,0 +1,250 @@
1
+ /**
2
+ * astroengine interpretation context -- a chart projected into typed, addressable
3
+ * "fact atoms" for an interpretation layer to consume.
4
+ *
5
+ * Caelus stops at validated geometry; this is the seam where interpretation
6
+ * begins. It does NOT interpret. It normalizes a {@link Chart}'s facts --
7
+ * placements, aspects, classical configurations, the structural signature, the
8
+ * angles -- into a flat list of atoms, each with a stable id, a transparent
9
+ * salience score, and a plain-language rendering. Rule-based and LLM-based
10
+ * interpreters alike build on this substrate: a rule corpus matches atoms by
11
+ * `kind`/`id`, and an LLM reads the `text` and cites the `id`.
12
+ *
13
+ * Salience is explicit and overridable (see {@link SalienceWeights}), never a
14
+ * magic number -- it ranks atoms so a reader can lead with what is prominent
15
+ * (luminaries, angular placements, the chart ruler, tight and hard aspects,
16
+ * whole configurations) without the engine asserting meaning.
17
+ *
18
+ * This is TS-side framework code, not ephemeris: there is no Swiss Ephemeris
19
+ * oracle for "which facts matter," so it is unit-tested for structure rather
20
+ * than pinned by a parity golden.
21
+ */
22
+ import { mod } from "./core.js";
23
+ import { SIGNS, DOMICILE, EXALTATION } from "./chart.js";
24
+ import { detectPatterns } from "./patterns.js";
25
+ import { chartSignature } from "./signature.js";
26
+ import { TRIPLICITY } from "./dignity-score.js";
27
+ const LUMINARIES = new Set(["sun", "moon"]);
28
+ const ANGULAR_HOUSES = new Set([1, 4, 7, 10]);
29
+ const HARD_ASPECTS = new Set(["conjunction", "square", "opposition"]);
30
+ /** The seven classical planets that participate in the dispositor scheme. */
31
+ const CLASSICAL = ["sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn"];
32
+ /** Traditional domicile ruler of each sign (index 0-11), inverted from
33
+ * {@link DOMICILE}: the body that rules a body's sign disposits it. */
34
+ const SIGN_RULER = (() => {
35
+ const r = new Array(12);
36
+ for (const [body, signs] of Object.entries(DOMICILE)) {
37
+ for (const s of signs)
38
+ r[s] = body;
39
+ }
40
+ return r;
41
+ })();
42
+ /** Body exalted in each sign (index 0-11), or undefined; inverted from
43
+ * {@link EXALTATION}. */
44
+ const SIGN_EXALT = (() => {
45
+ const r = new Array(12);
46
+ for (const [body, sign] of Object.entries(EXALTATION))
47
+ r[sign] = body;
48
+ return r;
49
+ })();
50
+ /** How a reception's dignities rank (stronger = larger), for the salience
51
+ * scaling and the `by` summary. */
52
+ const DIGNITY_RANK = { domicile: 3, exaltation: 2, triplicity: 1 };
53
+ export const DEFAULT_SALIENCE = {
54
+ base: 1, luminary: 1.5, angular: 1, chartRuler: 1,
55
+ dignity: 0.5, hardAspect: 1, pattern: 4, dispositor: 0.5, reception: 2,
56
+ };
57
+ /** How much to keep of a time-sensitive atom's salience at each certainty -- the
58
+ * Moon and the angles move fastest, so an uncertain instant trusts them least. */
59
+ const TIME_SENSITIVE_KEEP = {
60
+ exact: 1, approximate: 0.7, representative: 0.6, none: 0.5,
61
+ };
62
+ /** Time-sensitive atoms: the angles (rotate ~15°/h) and anything about the Moon
63
+ * (~13°/day), the fastest-shifting facts under a time error. */
64
+ function timeSensitive(atom) {
65
+ return atom.kind === "angle" || atom.bodies.includes("moon");
66
+ }
67
+ function title(body) {
68
+ return body.split("_").map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
69
+ }
70
+ function humanizePattern(kind) {
71
+ const special = {
72
+ t_square: "T-square", grand_trine: "Grand trine", grand_cross: "Grand cross",
73
+ mystic_rectangle: "Mystic rectangle", stellium_sign: "Stellium",
74
+ stellium_house: "Stellium",
75
+ };
76
+ return special[kind] ?? title(kind);
77
+ }
78
+ /**
79
+ * Project a {@link Chart} into a ranked list of {@link FactAtom}s -- the
80
+ * substrate an interpretation layer consumes. Pure and deterministic; computes
81
+ * applying/separating and a normalized strength for each aspect that the bare
82
+ * {@link Chart.aspects} list omits.
83
+ *
84
+ * @param chart A chart from {@link Engine.chart} / {@link Engine.chartAt}.
85
+ * @param opts Salience overrides, orb policy, and precomputed reductions.
86
+ * @returns The {@link InterpretationContext}; `atoms` are sorted by salience.
87
+ */
88
+ export function interpretationContext(chart, opts = {}) {
89
+ const w = { ...DEFAULT_SALIENCE, ...opts.salience };
90
+ const sig = opts.signature ?? chartSignature(chart);
91
+ const patterns = opts.patterns ?? detectPatterns(chart);
92
+ const atoms = [];
93
+ // Placements: one atom per present body.
94
+ for (const [body, p] of Object.entries(chart.bodies)) {
95
+ if (!p)
96
+ continue;
97
+ let salience = w.base;
98
+ if (LUMINARIES.has(body))
99
+ salience += w.luminary;
100
+ if (ANGULAR_HOUSES.has(p.house))
101
+ salience += w.angular;
102
+ if (sig.ruler === body)
103
+ salience += w.chartRuler;
104
+ salience += w.dignity * p.dignities.length;
105
+ const extra = [
106
+ p.retrograde ? "retrograde" : null,
107
+ ...p.dignities,
108
+ ].filter(Boolean);
109
+ atoms.push({
110
+ id: `placement:${body}`, kind: "placement", bodies: [body], salience,
111
+ body, sign: p.sign, signDeg: p.signDeg, house: p.house,
112
+ retrograde: p.retrograde, dignities: p.dignities,
113
+ text: `${title(body)} in ${p.sign}, house ${p.house}`
114
+ + (extra.length ? ` (${extra.join(", ")})` : ""),
115
+ });
116
+ }
117
+ // Aspects: phase and strength come straight from the enriched chart aspect.
118
+ for (const asp of chart.aspects) {
119
+ let salience = w.base + asp.strength;
120
+ if (HARD_ASPECTS.has(asp.aspect))
121
+ salience += w.hardAspect;
122
+ if (LUMINARIES.has(asp.a) || LUMINARIES.has(asp.b))
123
+ salience += w.luminary;
124
+ const [x, y] = [asp.a, asp.b].sort();
125
+ atoms.push({
126
+ id: `aspect:${x}~${y}:${asp.aspect}`, kind: "aspect", bodies: [asp.a, asp.b],
127
+ salience, a: asp.a, b: asp.b, aspect: asp.aspect, orb: asp.orb,
128
+ phase: asp.phase, strength: asp.strength,
129
+ text: `${title(asp.a)} ${asp.aspect} ${title(asp.b)} `
130
+ + `(${asp.phase}, orb ${Math.abs(asp.orb).toFixed(1)}°)`,
131
+ });
132
+ }
133
+ // Configurations.
134
+ for (const pat of patterns) {
135
+ let salience = w.pattern;
136
+ if (pat.bodies.some((b) => LUMINARIES.has(b)))
137
+ salience += w.luminary;
138
+ const names = pat.bodies.map(title).join(", ");
139
+ atoms.push({
140
+ id: `pattern:${pat.kind}:${pat.bodies.join("-")}`, kind: "pattern",
141
+ bodies: pat.bodies, salience, pattern: pat.kind, apex: pat.apex,
142
+ text: `${humanizePattern(pat.kind)}: ${names}`
143
+ + (pat.apex ? ` (apex ${title(pat.apex)})` : "")
144
+ + (pat.sign ? ` in ${pat.sign}` : ""),
145
+ });
146
+ }
147
+ // Structural signature: the dominant facets and the chart ruler.
148
+ const sigAtom = (facet, value, text) => {
149
+ if (value === null)
150
+ return;
151
+ atoms.push({
152
+ id: `signature:${facet}:${value}`, kind: "signature",
153
+ bodies: facet === "ruler" ? [value] : [], salience: w.base + 1,
154
+ facet, value, text,
155
+ });
156
+ };
157
+ sigAtom("element", sig.dominant.element, `${title(sig.dominant.element)} is the dominant element`);
158
+ sigAtom("modality", sig.dominant.modality, `${title(sig.dominant.modality)} is the dominant modality`);
159
+ sigAtom("sign", sig.dominant.sign, `${sig.dominant.sign} is the most-occupied sign`);
160
+ sigAtom("ruler", sig.ruler, `${title(sig.ruler ?? "")} is the chart ruler`);
161
+ // Dispositors: the classical ruler of each classical planet's sign, plus any
162
+ // mutual receptions (a disposits b and b disposits a) among them.
163
+ const dispositorOf = (body) => {
164
+ const p = chart.bodies[body];
165
+ return p ? SIGN_RULER[Math.floor(mod(p.lon, 360) / 30)] : null;
166
+ };
167
+ for (const body of CLASSICAL) {
168
+ if (!chart.bodies[body])
169
+ continue;
170
+ const disp = dispositorOf(body);
171
+ const final = disp === body;
172
+ let salience = w.base + w.dispositor + (final ? w.dispositor : 0);
173
+ if (LUMINARIES.has(body))
174
+ salience += w.luminary;
175
+ atoms.push({
176
+ id: `dispositor:${body}`, kind: "dispositor", bodies: [body], salience,
177
+ body, dispositor: disp, final,
178
+ text: final
179
+ ? `${title(body)} is in its own domicile (final dispositor)`
180
+ : `${title(body)} is disposited by ${title(disp)}`,
181
+ });
182
+ }
183
+ // Reception (mutual): each body holds a dignity in the other's sign. Checked
184
+ // by domicile, exaltation, and the sect's triplicity ruler (sect = day when
185
+ // the Sun is above the horizon, houses 7-12). `by` names the strongest
186
+ // dignity each direction; salience scales with the weaker link.
187
+ const sunHouse = chart.bodies.sun?.house;
188
+ const sect = sunHouse !== undefined && sunHouse >= 7 ? 0 : 1; // 0 day, 1 night
189
+ const signOf = (body) => Math.floor(mod(chart.bodies[body].lon, 360) / 30);
190
+ const receives = (a, otherSign) => {
191
+ const ds = [];
192
+ if (SIGN_RULER[otherSign] === a)
193
+ ds.push("domicile");
194
+ if (SIGN_EXALT[otherSign] === a)
195
+ ds.push("exaltation");
196
+ if (TRIPLICITY[otherSign % 4][sect] === a)
197
+ ds.push("triplicity");
198
+ return ds;
199
+ };
200
+ const strongest = (ds) => ds.reduce((best, d) => (DIGNITY_RANK[d] > DIGNITY_RANK[best] ? d : best), ds[0]);
201
+ for (let i = 0; i < CLASSICAL.length; i++) {
202
+ for (let j = i + 1; j < CLASSICAL.length; j++) {
203
+ const a = CLASSICAL[i];
204
+ const b = CLASSICAL[j];
205
+ if (!chart.bodies[a] || !chart.bodies[b])
206
+ continue;
207
+ const aRec = receives(a, signOf(b));
208
+ const bRec = receives(b, signOf(a));
209
+ if (!aRec.length || !bRec.length)
210
+ continue;
211
+ const da = strongest(aRec);
212
+ const db = strongest(bRec);
213
+ const by = da === db ? da : [da, db].sort().join("-");
214
+ let salience = w.base + w.reception * (Math.min(DIGNITY_RANK[da], DIGNITY_RANK[db]) / 3);
215
+ if (LUMINARIES.has(a) || LUMINARIES.has(b))
216
+ salience += w.luminary;
217
+ atoms.push({
218
+ id: `reception:${a}~${b}`, kind: "reception", bodies: [a, b], salience, by,
219
+ text: `Mutual reception: ${title(a)} and ${title(b)} (${by})`,
220
+ });
221
+ }
222
+ }
223
+ // Angles.
224
+ const angleAtom = (angle, lon) => {
225
+ const sign = SIGNS[Math.floor(mod(lon, 360) / 30)];
226
+ const label = { asc: "Ascendant", mc: "Midheaven", vertex: "Vertex", eastPoint: "East Point" }[angle];
227
+ atoms.push({
228
+ id: `angle:${angle}`, kind: "angle", bodies: [], salience: w.base + w.angular,
229
+ angle, sign, signDeg: mod(lon, 30),
230
+ text: `${label} in ${sign}`,
231
+ });
232
+ };
233
+ angleAtom("asc", chart.angles.asc);
234
+ angleAtom("mc", chart.angles.mc);
235
+ angleAtom("vertex", chart.angles.vertex);
236
+ angleAtom("eastPoint", chart.angles.eastPoint);
237
+ // An inexact instant trusts the fast-moving facts least.
238
+ const prov = opts.provenance;
239
+ if (prov?.certainty && prov.certainty !== "exact") {
240
+ const keep = TIME_SENSITIVE_KEEP[prov.certainty];
241
+ for (const a of atoms)
242
+ if (timeSensitive(a))
243
+ a.salience *= keep;
244
+ }
245
+ atoms.sort((m, n) => n.salience - m.salience || (m.id < n.id ? -1 : 1));
246
+ return {
247
+ jdUt: chart.jdUt, zodiac: chart.zodiac, atoms,
248
+ realm: prov?.realm, certainty: prov?.certainty,
249
+ };
250
+ }
@@ -34,8 +34,11 @@ export function loadNodeData(dir, level = "embedded", moonTier = "full") {
34
34
  if (existsSync(join(dir, "fixed_stars.json"))) {
35
35
  data.fixedStars = j("fixed_stars.json");
36
36
  }
37
- // asteroid packs (Horizons fits): loaded when present, ~380 KB total
38
- for (const b of ["ceres", "pallas", "juno", "vesta", "pholus"]) {
37
+ // asteroid packs (Horizons fits): loaded when present, ~380 KB total.
38
+ // `pluto` is optional too: when a wide-range Chebyshev pack is present it
39
+ // supersedes the embedded Meeus ch.37 series (valid 1885-2099) above, so
40
+ // Pluto extends past that window at full precision; see fit_pluto.py.
41
+ for (const b of ["ceres", "pallas", "juno", "vesta", "pholus", "pluto"]) {
39
42
  if (existsSync(join(dir, `${b}_cheb.json`))) {
40
43
  (data.chebPacks ??= {})[b] = j(`${b}_cheb.json`);
41
44
  }
@@ -25,3 +25,31 @@ export interface Paran {
25
25
  * @returns The co-angular pairs as {@link Paran} objects.
26
26
  */
27
27
  export declare function parans(engine: Engine, jd: number, lat: number, bodies?: string[], toleranceMin?: number): Paran[];
28
+ /**
29
+ * The four angle crossings of a fixed `star` over the day from `jd` at latitude
30
+ * `lat`: the meridian transits always occur; `rise`/`set` are absent when the
31
+ * star is circumpolar or never rises.
32
+ */
33
+ export declare function starAngleTimes(engine: Engine, star: string, jd: number, lat: number): Record<string, number>;
34
+ /** One star-to-body paran: a fixed star and a body on angles at ~the same time. */
35
+ export interface StarParan {
36
+ star: string;
37
+ star_angle: string;
38
+ body: string;
39
+ body_angle: string;
40
+ jd: number;
41
+ gap_min: number;
42
+ }
43
+ /**
44
+ * Star-to-body parans over the day from `jd` (UT) at latitude `lat`: a fixed
45
+ * star and a moving body simultaneously on angles within `toleranceMin`
46
+ * minutes — Brady's fixed-star parans. Ordered by (star, body, jd).
47
+ *
48
+ * @param engine An engine whose data pack includes the fixed-star catalog.
49
+ * @param jd Julian Day in UT (the 24-hour window starts here).
50
+ * @param lat Geographic latitude in degrees, north positive.
51
+ * @param stars Catalog star names to test (see {@link Engine.starNames}).
52
+ * @param bodies Bodies to consider; defaults to the seven classical planets.
53
+ * @param toleranceMin The paran window in minutes (default 30).
54
+ */
55
+ export declare function starParans(engine: Engine, jd: number, lat: number, stars: string[], bodies?: string[], toleranceMin?: number): StarParan[];