⎇ 7eb620aa170f825b5dec6ff454baac07c48bd9f1
crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(tahuantinsuyu): GR dual-ring + topo ascensional pegado al dial + coords
1//! `tahuantinsuyu-engine` — bridge entre el modelo agnóstico y
2//! `eternal-astrology`.
3//!
4//! Recibe un `Chart` del modelo + un `ChartKind` y devuelve un
5//! [`RenderModel`] que describe la geometría a pintar **sin** acoplar
6//! el canvas a tipos de la librería astronómica. El canvas habla
7//! grados decimales, radios normalizados y kinds simbólicos.
8//!
9//! ## Por qué un RenderModel intermedio
10//!
11//! 1. El canvas no debería caer si cambia el shape de `NatalChart`
12//! upstream.
13//! 2. Tests del canvas: podemos generar `RenderModel`s sintéticos sin
14//! arrancar eternal.
15//! 3. Cada `ChartKind` produce el mismo shape genérico → el render
16//! coordina N módulos sin saber qué calcularon.
17//!
18//! ## Feature `eternal-bridge`
19//!
20//! - **on** (default): [`compute`] abre una `EphemerisSession` VSOP2013
21//! compartida y corre la pipeline real.
22//! - **off**: [`compute`] cae a [`compute_mock`] — útil para tests +
23//! builds sin eternal checked out.
24
25#![forbid(unsafe_code)]
26#![warn(rust_2018_idioms)]
27
28use serde::{Deserialize, Serialize};
29use thiserror::Error;
30
31pub use tahuantinsuyu_model::{Chart, ChartId, ChartKind};
32
33// `Chart` reexportado arriba es lo que `PipelineRequest::Synastry`
34// transporta — el caller (shell) lee del Store y pasa el Chart entero
35// para que el bridge construya su NatalChart en eternal.
36
37#[cfg(feature = "eternal-bridge")]
38mod bridge;
39#[cfg(feature = "eternal-bridge")]
40mod dignity;
41#[cfg(feature = "eternal-bridge")]
42mod natal_cache;
43#[cfg(feature = "eternal-bridge")]
44pub mod svg_export;
45
46// =====================================================================
47// RenderModel — lo que el canvas necesita pintar
48// =====================================================================
49
50/// Resultado agnóstico de un cómputo astrológico, listo para renderizar.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct RenderModel {
53 pub chart_id: ChartId,
54 pub chart_kind: ChartKind,
55 pub title: String,
56 #[serde(default)]
57 pub subtitle: Option<String>,
58 pub compute_ms: u64,
59
60 // ─── Ángulos del chart (grados eclípticos, 0..360) ───────────────
61 /// Ascendente — punto fijo de rotación del lienzo. La rueda se gira
62 /// de modo que el Asc cae a las 9 (lado izquierdo).
63 pub ascendant_deg: f32,
64 pub midheaven_deg: f32,
65 pub descendant_deg: f32,
66 pub imum_coeli_deg: f32,
67
68 /// Capas a pintar. Orden = z-order ascendente.
69 pub layers: Vec<Layer>,
70 /// Metadata humana por overlay activo (transit, progresión,
71 /// sinastría, retorno...). Vacío para una carta natal pura. La UI
72 /// la pinta como badges en el footer.
73 #[serde(default)]
74 pub overlays: Vec<OverlayMeta>,
75 /// Lista paralela a las LineSeg de aspectos — uno por aspecto
76 /// natal o cross. Ordenado por `orb_deg` ascendente (los más
77 /// cerrados primero). La UI lo usa para la lista textual.
78 #[serde(default)]
79 pub aspect_summary: Vec<AspectSummary>,
80 /// Grupos uranianos detectados (cuerpos en la misma posición mod 90).
81 /// Vacío sino se activó el módulo Uranian.
82 #[serde(default)]
83 pub uranian_groups: Vec<UranianGroup>,
84}
85
86/// Etiqueta legible de un overlay para el footer del canvas. La engine
87/// la pushea desde cada `build_*_overlay`; el canvas solo lee y pinta.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct OverlayMeta {
90 pub module_id: String,
91 /// Etiqueta corta — ej. "Tránsito ahora", "Progresión 38.2a",
92 /// "Sinastría · Ana", "Saturn return 29a".
93 pub label: String,
94}
95
96/// Grupo de cuerpos natales que caen en la misma posición del
97/// dial uraniano de 90° (su longitud zodiacal módulo 90 es igual o
98/// muy cercana). En la astrología uraniana esto es una "fórmula" o
99/// "axis" — los cuerpos están en correspondencia simbólica directa
100/// porque comparten un cuadrante simétrico.
101///
102/// Solo se emiten grupos con 2+ miembros (los singletons no son
103/// fórmulas). La engine los ordena por proximidad al ε de tolerancia.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct UranianGroup {
106 /// Identificadores agnósticos de los cuerpos en el grupo
107 /// (ej. `["sun", "jupiter", "saturn"]`).
108 pub bodies: Vec<String>,
109 /// Posición en el dial de 90° (la longitud módulo 90).
110 pub mod90_deg: f64,
111}
112
113/// Resumen textual de un aspecto para listas legibles. La engine lo
114/// emite en paralelo con las `LineSeg` de la capa de aspectos, así
115/// el canvas no tiene que re-derivar nombres de cuerpos desde grados.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct AspectSummary {
118 /// Module al que pertenece — "natal", "transit", "synastry",
119 /// "progression", "solar_arc", "planetary_return".
120 pub module_id: String,
121 /// Identificador agnóstico del cuerpo "a" — "sun", "moon", etc.
122 pub from_body: String,
123 pub to_body: String,
124 /// Identificador del aspecto — "conjunction", "trine", etc.
125 pub kind: String,
126 pub orb_deg: f64,
127 /// `Some(true)` = applying, `Some(false)` = separating. `None` para
128 /// cross-aspects (sinastría/return) donde no se computa.
129 #[serde(default)]
130 pub applying: Option<bool>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct Layer {
135 pub module_id: String,
136 pub kind: LayerKind,
137 /// Radio normalizado [0, 1] sobre el lienzo — el canvas lo convierte
138 /// a píxeles. Permite stack de anillos.
139 pub ring: f32,
140 #[serde(default)]
141 pub z: i32,
142 pub geometry: Geometry,
143 #[serde(default)]
144 pub glyphs: Vec<Glyph>,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
148#[serde(rename_all = "snake_case")]
149pub enum LayerKind {
150 SignDial,
151 Houses,
152 Bodies,
153 Aspects,
154 Lots,
155 FixedStars,
156 Midpoints,
157 Outer,
158 Custom,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub enum Geometry {
163 GlyphsOnly,
164 /// Anillo dividido en sectores. `cusps_deg` son los grados
165 /// zodiacales donde van las divisiones radiales.
166 Ring { cusps_deg: Vec<f32> },
167 Lines(Vec<LineSeg>),
168 Points(Vec<PointMark>),
169}
170
171#[derive(Debug, Clone, Default, Serialize, Deserialize)]
172pub struct LineSeg {
173 /// Grados zodiacales del extremo "a".
174 pub from_deg: f32,
175 /// Grados zodiacales del extremo "b".
176 pub to_deg: f32,
177 /// Categoría simbólica (`"conjunction"`, `"trine"`, …) — el theme la
178 /// resuelve a color.
179 pub kind: String,
180 pub opacity: f32,
181 /// Cuerpo en el extremo "a" — populado para LineSegs de aspectos
182 /// (natal × natal, cross con overlays). Vacío en `Default::default`
183 /// para serde back-compat.
184 #[serde(default)]
185 pub from_body: String,
186 /// Cuerpo en el extremo "b".
187 #[serde(default)]
188 pub to_body: String,
189 /// Orb absoluto en grados (para tooltips).
190 #[serde(default)]
191 pub orb_deg: f32,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct PointMark {
196 pub deg: f32,
197 pub label: String,
198 pub tag: String,
199}
200
201#[derive(Debug, Clone, Default, Serialize, Deserialize)]
202pub struct Glyph {
203 /// Grado eclíptico [0, 360).
204 pub deg: f32,
205 /// Glyph simbólico — el theme/canvas lo mapea a unicode o imagen.
206 /// Ej: `"sun"`, `"moon"`, `"aries"`, `"asc"`, `"mc"`.
207 pub symbol: String,
208 #[serde(default)]
209 pub annotation: Option<String>,
210 #[serde(default)]
211 pub retrograde: bool,
212 #[serde(default)]
213 pub house: Option<u8>,
214 /// Marker de dignidad esencial, set solo cuando
215 /// `NatalOptions::show_dignities` está activo: `"+"` (domicilio),
216 /// `"·"` (exaltación), `"−"` (exilio), `"*"` (caída).
217 #[serde(default)]
218 pub dignity_marker: Option<String>,
219}
220
221// =====================================================================
222// Errores
223// =====================================================================
224
225#[derive(Debug, Error)]
226pub enum EngineError {
227 #[error("bridge a eternal-astrology no disponible (recompilá con feature `eternal-bridge`)")]
228 BridgeDisabled,
229 #[error("model: {0}")]
230 Model(#[from] tahuantinsuyu_model::ModelError),
231 #[error("eternal: {0}")]
232 Eternal(String),
233 #[error("kind {0:?} todavía no implementado")]
234 UnsupportedKind(ChartKind),
235}
236
237// =====================================================================
238// API pública
239// =====================================================================
240
241/// Pedidos que el host (Shell) eleva a la engine para componer un
242/// `RenderModel`. La capa natal **siempre** se computa; estos requests
243/// son **overlays adicionales**.
244///
245/// Cada variante mapea 1-a-1 con un Module declarado en
246/// `tahuantinsuyu-modules` por id string. Esto deja la engine como
247/// dueña única del cómputo (no depende del trait Module — los módulos
248/// son sólo metadata + UI controls).
249/// Módulos overlay que pintan en el mismo slot (outer ring del wheel)
250/// y por lo tanto son **mutuamente excluyentes** a nivel de UI: al
251/// prender uno, el shell debe apagar los otros. Single source of truth
252/// — el shell y el canvas leen de acá en vez de hardcodear listas.
253pub const OUTER_RING_MODULES: &[&str] = &["transit", "synastry", "planetary_return"];
254
255#[derive(Debug, Clone)]
256pub enum PipelineRequest {
257 /// `module_id = "transit"` — anillo externo con planetas al
258 /// instante actual (reloj de pared) + cross aspects natal × transit.
259 Transit,
260 /// `module_id = "progression"` — anillo interno con los planetas
261 /// progresados (método secundario "día por año") a la edad pedida
262 /// + cross aspects natal × progresada.
263 SecondaryProgression {
264 /// Edad simbólica en años a la que avanzar la carta. Para "la
265 /// edad de hoy", el shell la calcula a partir de `birth_data` +
266 /// `SystemTime::now`.
267 target_age_years: f64,
268 },
269 /// `module_id = "solar_arc"` — Solar Arc dirigido (default = "true
270 /// progressed Sun"): cada cuerpo y cada cusp natal se desplazan por
271 /// el mismo arco ≈ 1° por año de vida. Anillo interno bien adentro
272 /// + cross aspects natal × dirigida.
273 SolarArc {
274 target_age_years: f64,
275 },
276 /// `module_id = "synastry"` — bi-wheel: la natal en el centro, la
277 /// carta del partner en el anillo externo (compartido con Transit
278 /// — mutuamente excluyentes), cross aspects natal × partner.
279 /// El partner viene como `Chart` completo del shell.
280 Synastry {
281 partner_chart: Box<Chart>,
282 },
283 /// `module_id = "planetary_return"` — carta natal fresca al
284 /// instante del próximo retorno del cuerpo elegido a su posición
285 /// natal, para la edad pedida. Sun = retorno solar anual, Moon =
286 /// mensual, Júpiter/Saturno = generacionales. Anillo externo
287 /// compartido con Transit/Synastry — mutuamente excluyentes a
288 /// nivel de Shell.
289 PlanetaryReturn {
290 /// Identificador agnóstico del cuerpo ("sun", "moon",
291 /// "jupiter", …). El bridge lo mapea a `eternal_sky::Body`.
292 body: String,
293 target_age_years: f64,
294 /// Días extra que se suman al anchor de búsqueda (birth +
295 /// age*año). Para Solar return suele ser 0 (el return cae cerca
296 /// del cumpleaños); para Lunar return permite saltar de un
297 /// retorno mensual al siguiente (~28 días por click).
298 shift_days: i64,
299 },
300 /// `module_id = "midpoints"` — anillo de puntos medios entre pares
301 /// de cuerpos natales. Por simplicidad filtramos a los que
302 /// involucran al Sol o a la Luna (~10 puntos).
303 Midpoints,
304 /// `module_id = "composite"` — carta compuesta (midpoint composite,
305 /// método Davison) entre dos sujetos. Renderea los planetas
306 /// compuestos en un anillo interno propio (radio 0.36, entre solar
307 /// arc 0.40 y aspects). Útil para análisis de relaciones.
308 Composite {
309 partner_chart: Box<Chart>,
310 },
311 /// `module_id = "uranian"` — calcula los "ejes" del dial uraniano
312 /// de 90°: agrupa los cuerpos natales cuya longitud módulo 90 cae
313 /// dentro de una tolerancia (~2°). El resultado se publica en
314 /// `RenderModel.uranian_groups` para que la UI lo liste como
315 /// fórmulas analíticas. La visualización geométrica completa del
316 /// dial de 90° queda pendiente para una fase posterior.
317 Uranian,
318 /// `module_id = "lots"` — Lots arábigos (helenísticos) calculados
319 /// via `eternal_astrology::compute_lot`: Fortune, Spirit, Eros,
320 /// Necessity, Courage, Victory, Nemesis. Renderea cada lot como
321 /// un texto pequeño en el ring de bodies natales.
322 Lots,
323 /// `module_id = "fixed_stars"` — overlay con ~9 estrellas fijas
324 /// notables (Aldebaran, Regulus, Antares, Fomalhaut, Spica,
325 /// Sirius, Algol, Vega, Pollux). Posiciones tropicales J2000
326 /// aproximadas + precesión simple (~50.29″/año). Renderea como
327 /// marcadores chicos justo afuera del sign dial.
328 FixedStars,
329 /// `module_id = "topocentric"` — capa "ascensional": planetas
330 /// re-proyectados a longitud eclíptica topocéntrica (con paralaje
331 /// horizontal aplicada por cuerpo) + casas Polich-Page (sistema
332 /// topocéntrico de domificación). Visible sobre todo en la Luna
333 /// (~1° de shift); imperceptible en planetas exteriores. La capa
334 /// convive con la natal geocéntrica como overlay comparativo.
335 Topocentric,
336 /// `module_id = "pd_direct"` + `"pd_converse"` — Direcciones
337 /// Primarias del Sistema GR (García Rosas). Cada cuerpo natal se
338 /// proyecta dos veces: hacia adelante en el tiempo diurno
339 /// (direct) y hacia atrás (converse). Los dos resultados a la
340 /// edad pedida pintan un dual-ring para rectificación en vivo.
341 PrimaryDirections {
342 target_age_years: f64,
343 },
344}
345
346/// Opciones que afectan la pasada natal (qué aspectos pintar, qué
347/// multiplicador de orbe usar). Es independiente de los overlays.
348#[derive(Debug, Clone)]
349pub struct NatalOptions {
350 /// Incluir aspectos mayores (conj/opp/trine/square/sextile).
351 pub show_majors: bool,
352 /// Incluir aspectos menores (quincunx/semi-sextile/etc).
353 pub show_minors: bool,
354 /// Multiplicador uniforme sobre los orbes default. `1.0` = orbes
355 /// modern_western; `0.5` = tight; `2.0` = wide.
356 pub orb_multiplier: f64,
357 /// Si `true`, anota cada cuerpo natal con su dignidad esencial
358 /// (domicilio +, exaltación ·, exilio −, caída *). El canvas lo
359 /// renderea como sufijo del glifo.
360 pub show_dignities: bool,
361}
362
363impl Default for NatalOptions {
364 fn default() -> Self {
365 Self {
366 show_majors: true,
367 show_minors: false,
368 orb_multiplier: 1.0,
369 show_dignities: false,
370 }
371 }
372}
373
374/// Composición canónica: carta natal + todos los overlays pedidos.
375/// Equivalente a `compose_with_options` con `NatalOptions::default()`.
376pub fn compose(
377 chart: &Chart,
378 offset_minutes: i64,
379 requests: &[PipelineRequest],
380) -> Result<RenderModel, EngineError> {
381 compose_with_options(chart, offset_minutes, requests, &NatalOptions::default())
382}
383
384/// Variante que permite controlar qué aspectos natales se computan y
385/// con qué multiplicador de orbe.
386pub fn compose_with_options(
387 chart: &Chart,
388 offset_minutes: i64,
389 requests: &[PipelineRequest],
390 natal_options: &NatalOptions,
391) -> Result<RenderModel, EngineError> {
392 #[cfg(feature = "eternal-bridge")]
393 {
394 bridge::compose(chart, offset_minutes, requests, natal_options)
395 }
396 #[cfg(not(feature = "eternal-bridge"))]
397 {
398 let _ = (offset_minutes, requests, natal_options);
399 Ok(compute_mock(chart))
400 }
401}
402
403/// Atajo: natal sin overlays. Equivalente a `compose(chart, 0, &[])`.
404pub fn compute(chart: &Chart) -> Result<RenderModel, EngineError> {
405 compose(chart, 0, &[])
406}
407
408/// Atajo: natal con time-scrubbing pero sin overlays.
409pub fn compute_at_offset(chart: &Chart, offset_minutes: i64) -> Result<RenderModel, EngineError> {
410 compose(chart, offset_minutes, &[])
411}
412
413/// Atajo: natal + overlay de tránsitos al instante actual.
414pub fn compute_with_transits_at_now(
415 chart: &Chart,
416 offset_minutes: i64,
417) -> Result<RenderModel, EngineError> {
418 compose(chart, offset_minutes, &[PipelineRequest::Transit])
419}
420
421/// Helper retrocompatible: construye un `PlanetaryReturn` con
422/// `shift_days = 0`. Útil para llamadores que no necesitan ajuste
423/// fino (todos los Solar return y muchos casos básicos).
424pub fn planetary_return_request(body: String, target_age_years: f64) -> PipelineRequest {
425 PipelineRequest::PlanetaryReturn {
426 body,
427 target_age_years,
428 shift_days: 0,
429 }
430}
431
432/// Stub determinista — útil para tests + para la UI sin eternal.
433pub fn compute_mock(chart: &Chart) -> RenderModel {
434 use std::time::Instant;
435 let t0 = Instant::now();
436
437 let sign_dial = Layer {
438 module_id: "natal".into(),
439 kind: LayerKind::SignDial,
440 ring: 1.0,
441 z: 0,
442 geometry: Geometry::Ring {
443 cusps_deg: (0..12).map(|i| (i as f32) * 30.0).collect(),
444 },
445 glyphs: (0..12)
446 .map(|i| Glyph {
447 deg: (i as f32) * 30.0 + 15.0,
448 symbol: ZODIAC_GLYPHS[i].into(),
449 annotation: None,
450 retrograde: false,
451 house: None,
452 dignity_marker: None,
453 })
454 .collect(),
455 };
456
457 RenderModel {
458 chart_id: chart.id,
459 chart_kind: chart.kind,
460 title: chart.label.clone(),
461 subtitle: chart.birth_data.birthplace_label.clone(),
462 compute_ms: t0.elapsed().as_millis() as u64,
463 ascendant_deg: 0.0,
464 midheaven_deg: 270.0,
465 descendant_deg: 180.0,
466 imum_coeli_deg: 90.0,
467 layers: vec![sign_dial],
468 overlays: Vec::new(),
469 aspect_summary: Vec::new(),
470 uranian_groups: Vec::new(),
471 }
472}
473
474const ZODIAC_GLYPHS: [&str; 12] = [
475 "aries",
476 "taurus",
477 "gemini",
478 "cancer",
479 "leo",
480 "virgo",
481 "libra",
482 "scorpio",
483 "sagittarius",
484 "capricorn",
485 "aquarius",
486 "pisces",
487];
488
489// =====================================================================
490// Tests
491// =====================================================================
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496 use tahuantinsuyu_model::{
497 Chart, ChartKind, ContactId, StoredBirthData, StoredChartConfig,
498 };
499
500 fn sample_chart() -> Chart {
501 Chart {
502 id: ChartId::new(),
503 contact_id: ContactId::new(),
504 kind: ChartKind::Natal,
505 label: "test".into(),
506 birth_data: StoredBirthData {
507 year: 1987,
508 month: 3,
509 day: 14,
510 hour: 5,
511 minute: 22,
512 second: 0.0,
513 tz_offset_minutes: -240,
514 latitude_deg: 10.4806,
515 longitude_deg: -66.9036,
516 altitude_m: 900.0,
517 time_certainty: Default::default(),
518 subject_name: None,
519 birthplace_label: None,
520 },
521 config: StoredChartConfig::default(),
522 related_chart_id: None,
523 created_at_ms: 0,
524 }
525 }
526
527 #[test]
528 fn mock_emits_sign_dial() {
529 let model = compute_mock(&sample_chart());
530 assert_eq!(model.layers.len(), 1);
531 assert!(matches!(model.layers[0].kind, LayerKind::SignDial));
532 assert_eq!(model.layers[0].glyphs.len(), 12);
533 }
534
535 #[cfg(feature = "eternal-bridge")]
536 #[test]
537 fn real_compute_natal_demo() {
538 let model = compute(&sample_chart()).expect("compute con eternal");
539 assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::SignDial)));
540 assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::Houses)));
541 assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::Bodies)));
542 // El Asc debe ser un grado válido.
543 assert!(model.ascendant_deg.is_finite());
544 assert!((0.0..360.0).contains(&model.ascendant_deg));
545 }
546
547 /// El cache de NatalChart debe hacer que la segunda llamada con
548 /// inputs idénticos sea sustancialmente más rápida que la primera.
549 /// Verificamos un piso del 4× — en práctica el ratio suele ser
550 /// >10× porque la primera carga VSOP2013 también.
551 #[cfg(feature = "eternal-bridge")]
552 #[test]
553 fn natal_cache_hits_are_faster() {
554 let chart = sample_chart();
555 // Warmup: abre la sesión de efemérides y puebla el cache.
556 let _ = compute(&chart).expect("warmup");
557
558 // Reset implícito: insertar una clave distinta no botaría la
559 // nuestra (cap=8) pero la marcaría como más vieja. Como solo
560 // tenemos 1 entrada, sigue al frente.
561 let t1 = std::time::Instant::now();
562 let _ = compute(&chart).expect("primera medida");
563 let cold_or_hot_1 = t1.elapsed();
564
565 let t2 = std::time::Instant::now();
566 let _ = compute(&chart).expect("segunda medida");
567 let hot = t2.elapsed();
568
569 // Después del warmup, las dos llamadas son hot. Para validar el
570 // efecto del cache, modificamos el offset_minutes para forzar
571 // un MISS y comparar contra un HIT.
572 use crate::PipelineRequest;
573 let t3 = std::time::Instant::now();
574 let _ = compose(&chart, 17, &[] as &[PipelineRequest])
575 .expect("miss con offset distinto");
576 let miss = t3.elapsed();
577
578 let t4 = std::time::Instant::now();
579 let _ = compose(&chart, 17, &[] as &[PipelineRequest])
580 .expect("hit con mismo offset");
581 let hit = t4.elapsed();
582
583 // Sanity: el hit debe ser estrictamente más rápido que el miss.
584 assert!(
585 hit < miss,
586 "cache hit ({:?}) debería ser más rápido que miss ({:?}); \
587 warmup={:?}, repeat={:?}",
588 hit, miss, cold_or_hot_1, hot
589 );
590 }
591}