ether-to-astro 1.2.0 → 1.3.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.
Files changed (66) hide show
  1. package/README.md +15 -5
  2. package/dist/astro-service/chart-output-service.d.ts +44 -0
  3. package/dist/astro-service/chart-output-service.js +110 -0
  4. package/dist/astro-service/date-input.d.ts +14 -0
  5. package/dist/astro-service/date-input.js +30 -0
  6. package/dist/astro-service/electional-service.d.ts +45 -0
  7. package/dist/astro-service/electional-service.js +305 -0
  8. package/dist/astro-service/natal-service.d.ts +41 -0
  9. package/dist/astro-service/natal-service.js +179 -0
  10. package/dist/astro-service/rising-sign-service.d.ts +37 -0
  11. package/dist/astro-service/rising-sign-service.js +137 -0
  12. package/dist/astro-service/service-types.d.ts +82 -0
  13. package/dist/astro-service/service-types.js +1 -0
  14. package/dist/astro-service/shared.d.ts +65 -0
  15. package/dist/astro-service/shared.js +98 -0
  16. package/dist/astro-service/sky-service.d.ts +48 -0
  17. package/dist/astro-service/sky-service.js +144 -0
  18. package/dist/astro-service/transit-service.d.ts +82 -0
  19. package/dist/astro-service/transit-service.js +353 -0
  20. package/dist/astro-service.d.ts +101 -89
  21. package/dist/astro-service.js +162 -1042
  22. package/dist/tool-registry.js +1 -1
  23. package/docs/product/architecture-boundaries.md +8 -0
  24. package/docs/releases/1.3.0.md +51 -0
  25. package/docs/releases/README.md +17 -0
  26. package/package.json +4 -1
  27. package/src/astro-service/chart-output-service.ts +155 -0
  28. package/src/astro-service/date-input.ts +40 -0
  29. package/src/astro-service/electional-service.ts +395 -0
  30. package/src/astro-service/natal-service.ts +235 -0
  31. package/src/astro-service/rising-sign-service.ts +181 -0
  32. package/src/astro-service/service-types.ts +90 -0
  33. package/src/astro-service/shared.ts +128 -0
  34. package/src/astro-service/sky-service.ts +191 -0
  35. package/src/astro-service/transit-service.ts +507 -0
  36. package/src/astro-service.ts +177 -1386
  37. package/src/tool-registry.ts +1 -1
  38. package/tests/README.md +15 -0
  39. package/tests/property/electional-service.property.test.ts +67 -0
  40. package/tests/property/helpers/arbitraries.ts +126 -0
  41. package/tests/property/helpers/config.ts +52 -0
  42. package/tests/property/helpers/runtime.ts +12 -0
  43. package/tests/property/houses.property.test.ts +74 -0
  44. package/tests/property/rising-sign-service.property.test.ts +255 -0
  45. package/tests/property/service-transits.property.test.ts +154 -0
  46. package/tests/property/time-utils.property.test.ts +91 -0
  47. package/tests/property/transits.property.test.ts +113 -0
  48. package/tests/unit/astro-service/chart-output-service.test.ts +102 -0
  49. package/tests/unit/astro-service/electional-service.test.ts +182 -0
  50. package/tests/unit/astro-service/natal-service.test.ts +126 -0
  51. package/tests/unit/astro-service/rising-sign-service.test.ts +145 -0
  52. package/tests/unit/astro-service/sky-service.test.ts +130 -0
  53. package/tests/unit/astro-service/transit-service.test.ts +312 -0
  54. package/tests/unit/astro-service.test.ts +136 -781
  55. package/tests/unit/rising-sign-windows.test.ts +93 -0
  56. package/tests/unit/tool-registry.test.ts +11 -0
  57. package/tests/validation/README.md +14 -0
  58. package/tests/validation/adapters/internal.ts +234 -4
  59. package/tests/validation/compare/electional.ts +151 -0
  60. package/tests/validation/compare/rising-sign-windows.ts +347 -0
  61. package/tests/validation/compare/service-transits.ts +205 -0
  62. package/tests/validation/fixtures/electional/core.ts +88 -0
  63. package/tests/validation/fixtures/rising-sign-windows/core.ts +57 -0
  64. package/tests/validation/fixtures/service-transits/core.ts +89 -0
  65. package/tests/validation/utils/fixtureTypes.ts +139 -1
  66. package/tests/validation/validation.spec.ts +82 -0
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { compareRisingSignModePrecision } from '../validation/compare/rising-sign-windows.js';
3
+ import type { InternalValidationAdapter } from '../validation/adapters/internal.js';
4
+ import type {
5
+ NormalizedRisingSignWindowResult,
6
+ RisingSignModeComparisonFixture,
7
+ } from '../validation/utils/fixtureTypes.js';
8
+ import { ValidationReport } from '../validation/utils/report.js';
9
+
10
+ describe('When comparing rising-sign window mode precision', () => {
11
+ it('keeps checking exact precision when approximate skips intermediate exact signs', () => {
12
+ const fixture: RisingSignModeComparisonFixture = {
13
+ name: 'coarse approximate subsequence still checks exact boundary precision',
14
+ baseInput: {
15
+ date: '2026-03-28',
16
+ latitude: -65,
17
+ longitude: -180,
18
+ timezone: 'America/New_York',
19
+ },
20
+ };
21
+ const approximate: NormalizedRisingSignWindowResult = {
22
+ date: fixture.baseInput.date,
23
+ timezone: fixture.baseInput.timezone,
24
+ mode: 'approximate',
25
+ windows: [
26
+ {
27
+ sign: 'Cancer',
28
+ start: '2026-03-28T00:00:00.000Z',
29
+ end: '2026-03-28T02:00:00.000Z',
30
+ durationMs: 2 * 60 * 60 * 1000,
31
+ },
32
+ {
33
+ sign: 'Libra',
34
+ start: '2026-03-28T02:00:00.000Z',
35
+ end: '2026-03-28T06:00:00.000Z',
36
+ durationMs: 4 * 60 * 60 * 1000,
37
+ },
38
+ ],
39
+ };
40
+ const exact: NormalizedRisingSignWindowResult = {
41
+ date: fixture.baseInput.date,
42
+ timezone: fixture.baseInput.timezone,
43
+ mode: 'exact',
44
+ windows: [
45
+ {
46
+ sign: 'Cancer',
47
+ start: '2026-03-28T00:00:00.000Z',
48
+ end: '2026-03-28T01:30:00.000Z',
49
+ durationMs: 90 * 60 * 1000,
50
+ },
51
+ {
52
+ sign: 'Leo',
53
+ start: '2026-03-28T01:30:00.000Z',
54
+ end: '2026-03-28T01:40:00.000Z',
55
+ durationMs: 10 * 60 * 1000,
56
+ },
57
+ {
58
+ sign: 'Virgo',
59
+ start: '2026-03-28T01:40:00.000Z',
60
+ end: '2026-03-28T01:50:00.000Z',
61
+ durationMs: 10 * 60 * 1000,
62
+ },
63
+ {
64
+ sign: 'Libra',
65
+ start: '2026-03-28T01:50:00.000Z',
66
+ end: '2026-03-28T06:00:00.000Z',
67
+ durationMs: 250 * 60 * 1000,
68
+ },
69
+ ],
70
+ };
71
+ const adapter = {
72
+ getAscendantSignAt(isoUtc: string): string {
73
+ return isoUtc < '2026-03-28T01:55:00.000Z' ? 'Cancer' : 'Leo';
74
+ },
75
+ } as InternalValidationAdapter;
76
+ const report = new ValidationReport();
77
+
78
+ compareRisingSignModePrecision(fixture, approximate, exact, adapter, report);
79
+
80
+ expect(report.hardFailures).toHaveLength(2);
81
+ expect(report.hardFailures[0]?.message).toBe(
82
+ 'Exact mode inserted intermediate sign windows where approximate mode reported a single boundary'
83
+ );
84
+ expect(report.hardFailures[1]?.message).toBe(
85
+ 'Exact mode boundary was less precise than approximate mode'
86
+ );
87
+ expect(report.hardFailures[1]?.details).toMatchObject({
88
+ actualTransitionIsoUtc: '2026-03-28T01:55:00.000Z',
89
+ approximateBoundary: '2026-03-28T02:00:00.000Z',
90
+ exactBoundary: '2026-03-28T01:30:00.000Z',
91
+ });
92
+ });
93
+ });
@@ -81,6 +81,17 @@ describe('When resolving tool specs from the registry', () => {
81
81
  });
82
82
  });
83
83
 
84
+ it('Given get_transits schema, then include_mundane description matches shipped mundane contract', () => {
85
+ const spec = getToolSpec('get_transits');
86
+ expect(spec).toBeDefined();
87
+ const includeMundane = (spec!.inputSchema as any).properties.include_mundane.description as string;
88
+
89
+ expect(includeMundane).toContain('planetary positions');
90
+ expect(includeMundane).toContain('mundane aspects');
91
+ expect(includeMundane).toContain('weather');
92
+ expect(includeMundane).toContain('mundane.days');
93
+ });
94
+
84
95
  it('Given simple state tools, then calls route to matching service methods', async () => {
85
96
  const service = makeService();
86
97
  const ctx = { service: service as any, natalChart: { name: 'chart' } as any };
@@ -16,14 +16,28 @@ Notes:
16
16
 
17
17
  - Core validation requires only Node + this repo.
18
18
  - Astrolog checks are optional and auto-skipped unless enabled and available.
19
+ - Astrolog parity is the repo's optional curated external-oracle lane.
19
20
  - Astrolog parity covers a curated 25-fixture corpus (positions, houses, transit snapshots, and edge UTC normalization cases).
20
21
  - Dense-scan root oracle is independent from production `findExactTransitTimes()`.
22
+ - The harness now validates both engine-level math and deterministic `AstroService` behavior.
21
23
  - Hard failures fail the test run.
22
24
  - Soft mismatches are logged as warnings.
23
25
  - A machine-readable report is written to `/tmp/astro-validation-report.json`.
24
26
  - Rise/set and eclipse sections are capability/smoke checks in this harness (exact parity references are not yet externalized there).
25
27
  - Astrolog house parity treats Whole Sign ASC/MC cusp proxies as non-comparable; Whole Sign checks focus on cusp parity, while ASC/MC proxy warnings are reserved for non-Whole-Sign systems.
26
28
 
29
+ Service-layer coverage now includes:
30
+
31
+ - Electional context invariants: sect classification, near-horizon warnings, house fallback warnings, and optional-field toggles.
32
+ - Rising-sign window invariants: full local-day coverage, DST-transition serialization, deterministic repeats, and exact-vs-approximate boundary precision.
33
+ - Service-level transit serialization: enriched placement fields, explicit calculation/reporting timezones, forecast grouping, and sign-boundary carry behavior.
34
+
35
+ Property testing is intentionally separate from this harness:
36
+
37
+ - `npm run test:property` runs generated `fast-check` invariants.
38
+ - Property tests do not use Astrolog parity.
39
+ - Astrolog remains a named, curated, opt-in oracle lane for credibility and explainable golden-case comparisons.
40
+
27
41
  Tolerance summary:
28
42
 
29
43
  - Position parity (same engine): `0.0001°` for longitude/latitude/speed
@@ -1,16 +1,37 @@
1
+ import { parseDateOnlyInput } from '../../../src/astro-service/date-input.js';
2
+ import type {
3
+ GetElectionalContextInput,
4
+ GetRisingSignWindowsInput,
5
+ GetTransitsInput,
6
+ } from '../../../src/astro-service/service-types.js';
7
+ import { getSignAndDegree, normalizeLongitude } from '../../../src/astro-service/shared.js';
8
+ import { AstroService } from '../../../src/astro-service.js';
1
9
  import { EclipseCalculator } from '../../../src/eclipses.js';
10
+ import type { McpStartupDefaults } from '../../../src/entrypoint.js';
2
11
  import { EphemerisCalculator } from '../../../src/ephemeris.js';
3
12
  import { HouseCalculator } from '../../../src/houses.js';
4
13
  import { RiseSetCalculator } from '../../../src/riseset.js';
14
+ import { localToUTC, utcToLocal } from '../../../src/time-utils.js';
5
15
  import { TransitCalculator } from '../../../src/transits.js';
6
- import type { PlanetPosition, Transit } from '../../../src/types.js';
16
+ import {
17
+ type NatalChart,
18
+ PLANETS,
19
+ type PlanetPosition,
20
+ type Transit,
21
+ ZODIAC_SIGNS,
22
+ } from '../../../src/types.js';
7
23
  import type {
8
24
  NormalizedBody,
9
25
  NormalizedEclipse,
26
+ NormalizedElectionalContext,
10
27
  NormalizedHouseResult,
11
28
  NormalizedRiseSet,
29
+ NormalizedRisingSignWindowResult,
12
30
  NormalizedRoot,
31
+ NormalizedServiceTransit,
32
+ NormalizedServiceTransitResult,
13
33
  NormalizedTransit,
34
+ ServiceTransitNatalFixture,
14
35
  } from '../utils/fixtureTypes.js';
15
36
 
16
37
  export class InternalValidationAdapter {
@@ -19,19 +40,21 @@ export class InternalValidationAdapter {
19
40
  readonly transitCalc: TransitCalculator;
20
41
  readonly riseSetCalc: RiseSetCalculator;
21
42
  readonly eclipseCalc: EclipseCalculator;
43
+ private readonly validationNow: Date;
22
44
 
23
- private constructor(ephem: EphemerisCalculator) {
45
+ private constructor(ephem: EphemerisCalculator, validationNow: Date) {
24
46
  this.ephem = ephem;
25
47
  this.houseCalc = new HouseCalculator(ephem);
26
48
  this.transitCalc = new TransitCalculator(ephem);
27
49
  this.riseSetCalc = new RiseSetCalculator(ephem);
28
50
  this.eclipseCalc = new EclipseCalculator(ephem);
51
+ this.validationNow = validationNow;
29
52
  }
30
53
 
31
- static async create(): Promise<InternalValidationAdapter> {
54
+ static async create(nowIsoUtc = '2024-03-26T12:00:00Z'): Promise<InternalValidationAdapter> {
32
55
  const ephem = new EphemerisCalculator();
33
56
  await ephem.init();
34
- return new InternalValidationAdapter(ephem);
57
+ return new InternalValidationAdapter(ephem, new Date(nowIsoUtc));
35
58
  }
36
59
 
37
60
  getPositions(isoUtc: string, planetIds: number[]): NormalizedBody[] {
@@ -139,6 +162,126 @@ export class InternalValidationAdapter {
139
162
  };
140
163
  }
141
164
 
165
+ getElectionalContext(input: GetElectionalContextInput): NormalizedElectionalContext {
166
+ const service = this.createService();
167
+ const result = service.getElectionalContext(input);
168
+ const data = result.data as {
169
+ input: { house_system: string; instant_utc: string };
170
+ sect: {
171
+ classification: 'day' | 'night';
172
+ is_day_chart: boolean;
173
+ sun_altitude_degrees: number;
174
+ };
175
+ moon: { applying_aspects?: unknown[] };
176
+ applying_aspects?: unknown[];
177
+ ruler_basics?: unknown;
178
+ meta: { warnings: string[] };
179
+ };
180
+
181
+ const instantUtc = new Date(data.input.instant_utc);
182
+ const jdUt = this.ephem.dateToJulianDay(instantUtc);
183
+ const sun = this.ephem.getPlanetPosition(PLANETS.SUN, jdUt);
184
+ const rawSunAltitudeDegrees = this.ephem.getHorizontalCoordinates(
185
+ jdUt,
186
+ sun,
187
+ input.longitude,
188
+ input.latitude
189
+ ).trueAltitude;
190
+
191
+ return {
192
+ houseSystem: data.input.house_system,
193
+ classification: data.sect.classification,
194
+ isDayChart: data.sect.is_day_chart,
195
+ sunAltitudeDegrees: data.sect.sun_altitude_degrees,
196
+ rawSunAltitudeDegrees,
197
+ sunAltitudeDisplaysZero:
198
+ data.sect.sun_altitude_degrees === 0 || Object.is(data.sect.sun_altitude_degrees, -0),
199
+ warnings: data.meta.warnings,
200
+ hasApplyingAspects: Array.isArray(data.applying_aspects),
201
+ applyingAspectCount: data.applying_aspects?.length ?? 0,
202
+ hasMoonApplyingAspects: Array.isArray(data.moon.applying_aspects),
203
+ moonApplyingAspectCount: data.moon.applying_aspects?.length ?? 0,
204
+ hasRulerBasics: data.ruler_basics !== undefined,
205
+ };
206
+ }
207
+
208
+ getRisingSignWindows(input: GetRisingSignWindowsInput): NormalizedRisingSignWindowResult {
209
+ const service = this.createService();
210
+ const result = service.getRisingSignWindows(input);
211
+ const data = result.data as {
212
+ date: string;
213
+ timezone: string;
214
+ mode: 'approximate' | 'exact';
215
+ windows: Array<{
216
+ sign: string;
217
+ start: string;
218
+ end: string;
219
+ durationMs: number;
220
+ }>;
221
+ };
222
+
223
+ return {
224
+ date: data.date,
225
+ timezone: data.timezone,
226
+ mode: data.mode,
227
+ windows: data.windows.map((window) => ({ ...window })),
228
+ };
229
+ }
230
+
231
+ getServiceTransits(input: {
232
+ natalChart: ServiceTransitNatalFixture;
233
+ transitInput: GetTransitsInput;
234
+ startupDefaults?: McpStartupDefaults;
235
+ }): NormalizedServiceTransitResult {
236
+ const service = this.createService(input.startupDefaults);
237
+ const targetDate = this.resolveTransitTargetDate(
238
+ input.transitInput.date,
239
+ input.natalChart.timezone
240
+ );
241
+ const natalChart = this.buildNatalChartFromOffsets(input.natalChart, targetDate);
242
+ const result = service.getTransits(natalChart, input.transitInput);
243
+ const data = result.data as {
244
+ mode?: 'snapshot' | 'best_hit' | 'forecast';
245
+ mode_source?: 'legacy_default' | 'explicit';
246
+ date?: string;
247
+ timezone: string;
248
+ calculation_timezone?: string;
249
+ reporting_timezone?: string;
250
+ days_ahead?: number;
251
+ window_start?: string;
252
+ window_end?: string;
253
+ transits?: Array<Record<string, unknown>>;
254
+ forecast?: Array<{
255
+ date: string;
256
+ transits: Array<Record<string, unknown>>;
257
+ }>;
258
+ };
259
+
260
+ return {
261
+ mode: data.mode,
262
+ modeSource: data.mode_source,
263
+ date: data.date,
264
+ timezone: data.timezone,
265
+ calculationTimezone: data.calculation_timezone,
266
+ reportingTimezone: data.reporting_timezone,
267
+ daysAhead: data.days_ahead,
268
+ windowStart: data.window_start,
269
+ windowEnd: data.window_end,
270
+ transits: data.transits?.map((transit) => this.normalizeServiceTransit(transit)),
271
+ forecast: data.forecast?.map((day) => ({
272
+ date: day.date,
273
+ transits: day.transits.map((transit) => this.normalizeServiceTransit(transit)),
274
+ })),
275
+ };
276
+ }
277
+
278
+ getAscendantSignAt(isoUtc: string, latitude: number, longitude: number): string {
279
+ const jd = this.ephem.dateToJulianDay(new Date(isoUtc));
280
+ const houses = this.houseCalc.calculateHouses(jd, latitude, longitude, 'P');
281
+ const ascendant = normalizeLongitude(houses.ascendant);
282
+ return ZODIAC_SIGNS[Math.floor(ascendant / 30)];
283
+ }
284
+
142
285
  canComputeRiseSet(): boolean {
143
286
  try {
144
287
  const probeJd = this.ephem.dateToJulianDay(new Date('2024-03-26T12:00:00Z'));
@@ -181,4 +324,91 @@ export class InternalValidationAdapter {
181
324
  isApplying: t.isApplying,
182
325
  };
183
326
  }
327
+
328
+ private createService(startupDefaults: McpStartupDefaults = {}): AstroService {
329
+ return new AstroService({
330
+ ephem: this.ephem,
331
+ transitCalc: this.transitCalc,
332
+ houseCalc: this.houseCalc,
333
+ riseSetCalc: this.riseSetCalc,
334
+ eclipseCalc: this.eclipseCalc,
335
+ mcpStartupDefaults: startupDefaults,
336
+ now: () => new Date(this.validationNow.getTime()),
337
+ });
338
+ }
339
+
340
+ private resolveTransitTargetDate(dateStr: string | undefined, calculationTimezone: string): Date {
341
+ if (dateStr) {
342
+ return localToUTC(parseDateOnlyInput(dateStr), calculationTimezone);
343
+ }
344
+
345
+ const localNow = utcToLocal(this.validationNow, calculationTimezone);
346
+ return localToUTC({ ...localNow, hour: 12, minute: 0, second: 0 }, calculationTimezone);
347
+ }
348
+
349
+ private buildNatalChartFromOffsets(
350
+ fixture: ServiceTransitNatalFixture,
351
+ targetDate: Date
352
+ ): NatalChart {
353
+ const targetJulianDay = this.ephem.dateToJulianDay(targetDate);
354
+ const birthInstant = new Date(fixture.julianDayIsoUtc);
355
+ const birthDate = utcToLocal(birthInstant, fixture.timezone);
356
+ const utcBirthDate = utcToLocal(birthInstant, 'UTC');
357
+
358
+ const planets = fixture.planetOffsets.map(
359
+ ({ transitingPlanetId, natalPlanetId, natalOffsetDegrees }) => {
360
+ const transiting = this.ephem.getPlanetPosition(transitingPlanetId, targetJulianDay);
361
+ const natalBase = this.ephem.getPlanetPosition(natalPlanetId, targetJulianDay);
362
+ const longitude = (transiting.longitude + natalOffsetDegrees + 360) % 360;
363
+ const placement = getSignAndDegree(longitude);
364
+
365
+ return {
366
+ ...natalBase,
367
+ planetId: natalPlanetId,
368
+ longitude,
369
+ sign: placement.sign,
370
+ degree: placement.degree,
371
+ };
372
+ }
373
+ );
374
+
375
+ return {
376
+ name: fixture.name,
377
+ birthDate,
378
+ location: {
379
+ latitude: fixture.latitude,
380
+ longitude: fixture.longitude,
381
+ timezone: fixture.timezone,
382
+ },
383
+ planets,
384
+ julianDay: this.ephem.dateToJulianDay(birthInstant),
385
+ houseSystem: fixture.houseSystem ?? 'P',
386
+ requestedHouseSystem: fixture.houseSystem,
387
+ utcDateTime: utcBirthDate,
388
+ };
389
+ }
390
+
391
+ private normalizeServiceTransit(transit: Record<string, unknown>): NormalizedServiceTransit {
392
+ return {
393
+ transitingPlanet: String(transit.transitingPlanet),
394
+ natalPlanet: String(transit.natalPlanet),
395
+ aspect: String(transit.aspect),
396
+ orb: Number(transit.orb),
397
+ exactTime: typeof transit.exactTime === 'string' ? transit.exactTime : undefined,
398
+ exactTimeStatus:
399
+ transit.exactTimeStatus === 'within_preview' ||
400
+ transit.exactTimeStatus === 'outside_preview' ||
401
+ transit.exactTimeStatus === 'not_found' ||
402
+ transit.exactTimeStatus === 'unsupported_body'
403
+ ? transit.exactTimeStatus
404
+ : undefined,
405
+ isApplying: Boolean(transit.isApplying),
406
+ transitSign: typeof transit.transitSign === 'string' ? transit.transitSign : undefined,
407
+ transitDegree: typeof transit.transitDegree === 'number' ? transit.transitDegree : undefined,
408
+ transitHouse: typeof transit.transitHouse === 'number' ? transit.transitHouse : undefined,
409
+ natalSign: typeof transit.natalSign === 'string' ? transit.natalSign : undefined,
410
+ natalDegree: typeof transit.natalDegree === 'number' ? transit.natalDegree : undefined,
411
+ natalHouse: typeof transit.natalHouse === 'number' ? transit.natalHouse : undefined,
412
+ };
413
+ }
184
414
  }
@@ -0,0 +1,151 @@
1
+ import type { ElectionalFixture, NormalizedElectionalContext } from '../utils/fixtureTypes.js';
2
+ import type { ValidationReport } from '../utils/report.js';
3
+
4
+ function matchesRawAltitudeSign(
5
+ value: number,
6
+ expected: 'positive' | 'negative' | 'zero'
7
+ ): boolean {
8
+ if (expected === 'zero') {
9
+ return Math.abs(value) < 1e-9;
10
+ }
11
+ return expected === 'positive' ? value > 0 : value < 0;
12
+ }
13
+
14
+ export function compareElectionalContext(
15
+ fixture: ElectionalFixture,
16
+ actual: NormalizedElectionalContext,
17
+ report: ValidationReport
18
+ ): void {
19
+ const { expected } = fixture;
20
+
21
+ if (actual.classification !== expected.classification) {
22
+ report.addHard({
23
+ fixture: fixture.name,
24
+ subsystem: 'electional',
25
+ expected: expected.classification,
26
+ actual: actual.classification,
27
+ delta: null,
28
+ tolerance: 'exact',
29
+ message: 'Sect classification mismatch',
30
+ details: actual,
31
+ });
32
+ }
33
+
34
+ if (actual.isDayChart !== expected.isDayChart) {
35
+ report.addHard({
36
+ fixture: fixture.name,
37
+ subsystem: 'electional',
38
+ expected: expected.isDayChart,
39
+ actual: actual.isDayChart,
40
+ delta: null,
41
+ tolerance: 'exact',
42
+ message: 'is_day_chart mismatch',
43
+ details: actual,
44
+ });
45
+ }
46
+
47
+ if (expected.houseSystem && actual.houseSystem !== expected.houseSystem) {
48
+ report.addHard({
49
+ fixture: fixture.name,
50
+ subsystem: 'electional',
51
+ expected: expected.houseSystem,
52
+ actual: actual.houseSystem,
53
+ delta: null,
54
+ tolerance: 'exact',
55
+ message: 'Resolved house system mismatch',
56
+ details: actual,
57
+ });
58
+ }
59
+
60
+ if (
61
+ expected.rawSunAltitudeSign &&
62
+ !matchesRawAltitudeSign(actual.rawSunAltitudeDegrees, expected.rawSunAltitudeSign)
63
+ ) {
64
+ report.addHard({
65
+ fixture: fixture.name,
66
+ subsystem: 'electional',
67
+ expected: expected.rawSunAltitudeSign,
68
+ actual: actual.rawSunAltitudeDegrees,
69
+ delta: null,
70
+ tolerance: 'sign only',
71
+ message: 'Raw Sun altitude sign mismatch',
72
+ details: actual,
73
+ });
74
+ }
75
+
76
+ if (
77
+ expected.sunAltitudeDisplaysZero !== undefined &&
78
+ actual.sunAltitudeDisplaysZero !== expected.sunAltitudeDisplaysZero
79
+ ) {
80
+ report.addHard({
81
+ fixture: fixture.name,
82
+ subsystem: 'electional',
83
+ expected: expected.sunAltitudeDisplaysZero,
84
+ actual: actual.sunAltitudeDisplaysZero,
85
+ delta: null,
86
+ tolerance: 'exact',
87
+ message: 'Rounded Sun altitude zero-display expectation mismatch',
88
+ details: actual,
89
+ });
90
+ }
91
+
92
+ for (const warning of expected.warningsContain ?? []) {
93
+ if (!actual.warnings.includes(warning)) {
94
+ report.addHard({
95
+ fixture: fixture.name,
96
+ subsystem: 'electional',
97
+ expected: warning,
98
+ actual: actual.warnings,
99
+ delta: null,
100
+ tolerance: 'contains',
101
+ message: 'Expected electional warning was not emitted',
102
+ details: actual,
103
+ });
104
+ }
105
+ }
106
+
107
+ if (
108
+ expected.hasApplyingAspects !== undefined &&
109
+ actual.hasApplyingAspects !== expected.hasApplyingAspects
110
+ ) {
111
+ report.addHard({
112
+ fixture: fixture.name,
113
+ subsystem: 'electional',
114
+ expected: expected.hasApplyingAspects,
115
+ actual: actual.hasApplyingAspects,
116
+ delta: null,
117
+ tolerance: 'exact',
118
+ message: 'Top-level applying-aspect presence mismatch',
119
+ details: actual,
120
+ });
121
+ }
122
+
123
+ if (
124
+ expected.hasMoonApplyingAspects !== undefined &&
125
+ actual.hasMoonApplyingAspects !== expected.hasMoonApplyingAspects
126
+ ) {
127
+ report.addHard({
128
+ fixture: fixture.name,
129
+ subsystem: 'electional',
130
+ expected: expected.hasMoonApplyingAspects,
131
+ actual: actual.hasMoonApplyingAspects,
132
+ delta: null,
133
+ tolerance: 'exact',
134
+ message: 'Moon applying-aspect presence mismatch',
135
+ details: actual,
136
+ });
137
+ }
138
+
139
+ if (expected.hasRulerBasics !== undefined && actual.hasRulerBasics !== expected.hasRulerBasics) {
140
+ report.addHard({
141
+ fixture: fixture.name,
142
+ subsystem: 'electional',
143
+ expected: expected.hasRulerBasics,
144
+ actual: actual.hasRulerBasics,
145
+ delta: null,
146
+ tolerance: 'exact',
147
+ message: 'Ruler-basics presence mismatch',
148
+ details: actual,
149
+ });
150
+ }
151
+ }