diff --git a/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs b/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs index 7a367ed..2f2642d 100644 --- a/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs +++ b/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs @@ -129,7 +129,7 @@ fn map_body(name: &str) -> Option { }) } -fn body_symbol(b: Body) -> &'static str { +pub(crate) fn body_symbol(b: Body) -> &'static str { match b { Body::Sun => "sun", Body::Moon => "moon", @@ -239,7 +239,7 @@ fn build_eternal_inputs( /// La clave incluye todos los campos de `StoredBirthData` y /// `StoredChartConfig` que afectan el cómputo; editar la carta invalida /// automáticamente la entrada. -fn compute_natal_chart( +pub(crate) fn compute_natal_chart( chart: &Chart, offset_minutes: i64, ) -> Result<(Arc, ChartConfig, Observer), EngineError> { @@ -594,13 +594,13 @@ fn build_topocentric_overlay( /// Orbe máximo (grados) para que una proyección primaria entre al HUD /// de triggers. ~2° ≈ 2 años de vida con el key Naibod. -const GR_HUD_ORB_DEG: f32 = 2.0; +pub(crate) const GR_HUD_ORB_DEG: f32 = 2.0; /// Micro-orbe de convergencia GR: 5 minutos de arco. Un punto natal /// tocado a la vez por un directo y un converso dentro de este orbe /// es un evento de rectificación. -const GR_EVENT_ORB_DEG: f32 = 5.0 / 60.0; +pub(crate) const GR_EVENT_ORB_DEG: f32 = 5.0 / 60.0; /// Tope de triggers en el HUD tras ordenar por orbe. -const GR_MAX_TRIGGERS: usize = 60; +pub(crate) const GR_MAX_TRIGGERS: usize = 60; /// GR dual-ring de Direcciones Primarias: a la edad pedida, cada /// cuerpo natal se proyecta dos veces — directa (rotación diurna diff --git a/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs index 0a8fb1e..5c3a8b3 100644 --- a/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs +++ b/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs @@ -36,9 +36,9 @@ pub use cosmobiologia_model::{Chart, ChartId, ChartKind}; // (`cosmobiologia_engine::Layer`, etc.) sin tener que cambiar // imports en el shell, canvas, modules, tree, panel... pub use cosmobiologia_render::{ - apply_harmonic, compute_gr_triggers, AspectSummary, Geometry, Glyph, GrDirection, GrTrigger, - Layer, LayerKind, LineSeg, OverlayMeta, PointMark, RenderModel, UranianGroup, - OUTER_RING_MODULES, + apply_harmonic, compute_gr_triggers, convergencia_minima, AspectSummary, Geometry, Glyph, + GrDirection, GrTrigger, Layer, LayerKind, LineSeg, OverlayMeta, PointMark, RenderModel, + UranianGroup, OUTER_RING_MODULES, }; // `Chart` reexportado arriba es lo que `PipelineRequest::Synastry` @@ -52,6 +52,8 @@ mod dignity; #[cfg(feature = "eternal-bridge")] mod natal_cache; #[cfg(feature = "eternal-bridge")] +mod rectify; +#[cfg(feature = "eternal-bridge")] pub mod svg_export; // ===================================================================== @@ -210,6 +212,53 @@ impl Default for NatalOptions { } } +// ===================================================================== +// Rectificador automático (Sistema GR) +// ===================================================================== + +/// Un evento conocido de la vida del sujeto — el ancla de la +/// rectificación. La hora de nacimiento verdadera es la que hace caer +/// los eventos reales sobre convergencias GR cerradas. +#[derive(Debug, Clone, Copy)] +pub struct EventoConocido { + /// Edad del sujeto, en años, cuando ocurrió el evento. + pub edad_years: f64, +} + +/// Resultado de un barrido de rectificación (ver [`rectificar`]). +#[derive(Debug, Clone)] +pub struct Rectificacion { + /// Desplazamiento, en minutos, sobre la hora registrada, que mejor + /// explica los eventos. `0` = la hora registrada ya es la mejor. + pub mejor_offset_minutos: i64, + /// Puntaje del mejor candidato: la suma de orbes de convergencia GR + /// sobre todos los eventos. Menor = mejor; es la «tensión» residual. + pub mejor_puntaje: f32, + /// El barrido completo: `(offset_minutos, puntaje)` por candidato, + /// ordenado por offset ascendente. La UI lo dibuja como una curva — + /// su valle marca la hora rectificada. + pub perfil: Vec<(i64, f32)>, +} + +/// Rectifica la hora de nacimiento por el Sistema GR. Barre las horas +/// candidatas en `[-ventana_min, +ventana_min]` minutos sobre la +/// registrada, paso a paso (`paso_min`); para cada candidata computa la +/// carta y, por cada evento conocido, mide la convergencia GR más +/// cerrada a esa edad. La hora del puntaje mínimo es la rectificada. +/// +/// `key` es la clave arco↔año: `"naibod"` (default) o `"ptolemy"`. +/// `Err` si la lista de eventos está vacía — sin anclas no hay búsqueda. +#[cfg(feature = "eternal-bridge")] +pub fn rectificar( + chart: &Chart, + eventos: &[EventoConocido], + ventana_min: i64, + paso_min: i64, + key: &str, +) -> Result { + rectify::rectificar(chart, eventos, ventana_min, paso_min, key) +} + /// Composición canónica: carta natal + todos los overlays pedidos. /// Equivalente a `compose_with_options` con `NatalOptions::default()`. pub fn compose( @@ -535,4 +584,32 @@ mod tests { .any(|(a, b)| (a - b).abs() > 0.01); assert!(moved, "el armónico debe mover los cuerpos"); } + + /// El rectificador barre la ventana entera, devuelve un perfil + /// ordenado y elige como mejor el candidato de puntaje mínimo. + #[cfg(feature = "eternal-bridge")] + #[test] + fn rectificar_barre_la_ventana_y_elige_el_minimo() { + let chart = sample_chart(); + let eventos = [ + EventoConocido { edad_years: 20.0 }, + EventoConocido { edad_years: 35.0 }, + ]; + let r = rectificar(&chart, &eventos, 10, 2, "naibod").expect("rectificar"); + + // Ventana ±10 min, paso 2 → offsets -10,-8,…,10 = 11 candidatos. + assert_eq!(r.perfil.len(), 11); + // El perfil va ordenado por offset ascendente. + for par in r.perfil.windows(2) { + assert!(par[0].0 < par[1].0, "perfil desordenado"); + } + // El mejor offset cae dentro de la ventana. + assert!(r.mejor_offset_minutos.abs() <= 10); + // Y su puntaje es, en efecto, el mínimo del perfil. + let minimo = r.perfil.iter().map(|(_, p)| *p).fold(f32::INFINITY, f32::min); + assert!((r.mejor_puntaje - minimo).abs() < 1e-4); + + // Sin eventos no hay ancla — debe ser un error. + assert!(rectificar(&chart, &[], 10, 2, "naibod").is_err()); + } } diff --git a/crates/modules/cosmobiologia/cosmobiologia-engine/src/rectify.rs b/crates/modules/cosmobiologia/cosmobiologia-engine/src/rectify.rs new file mode 100644 index 0000000..efbf4fd --- /dev/null +++ b/crates/modules/cosmobiologia/cosmobiologia-engine/src/rectify.rs @@ -0,0 +1,158 @@ +//! Rectificador automático — Sistema GR. +//! +//! La rectificación horaria responde a una pregunta vieja: si la hora de +//! nacimiento registrada es incierta, ¿cuál es la verdadera? El método GR +//! (García Rosas) la ataca con direcciones primarias: en la hora correcta, +//! los eventos reales de la vida del sujeto caen sobre **convergencias** — +//! un promisor directo y otro converso que se cruzan sobre un mismo punto +//! natal. +//! +//! Este módulo automatiza la búsqueda. Dada una carta, una ventana de horas +//! candidatas alrededor de la registrada, y una lista de eventos conocidos +//! (cada uno, una edad), **barre** las candidatas: para cada hora, computa +//! la carta y mide —con [`convergencia_minima`]— qué tan cerrada es la mejor +//! convergencia GR a la edad de cada evento. La hora cuyo puntaje total es +//! mínimo es la rectificada. +//! +//! El cómputo pesado —la carta natal por hora candidata— se delega a +//! `bridge::compute_natal_chart`, que cachea; la proyección primaria por +//! cuerpo es aritmética barata. La función de puntaje, [`convergencia_minima`], +//! es lógica pura y vive en `cosmobiologia-render`. + +use eternal_astrology::{ + directed_longitude, primary_direction::PrimaryDirection, DirectionKey as EDirectionKey, + NatalChart, +}; + +use crate::bridge::{ + body_symbol, compute_natal_chart, GR_EVENT_ORB_DEG, GR_HUD_ORB_DEG, GR_MAX_TRIGGERS, +}; +use crate::{ + compute_gr_triggers, convergencia_minima, Chart, EngineError, EventoConocido, GrDirection, + GrTrigger, Rectificacion, +}; + +/// Puntaje que se imputa a un evento cuando la carta candidata no halla +/// convergencia GR alguna a esa edad. Debe superar a cualquier suma real +/// de orbes (el HUD acota cada orbe a 2°, así que una convergencia real +/// nunca pasa de ~4°): así un candidato sin convergencias queda +/// inequívocamente por detrás de uno que sí las tiene. +const SIN_CONVERGENCIA: f32 = 8.0; + +/// Computa los triggers GR de una carta natal ya calculada, a una edad +/// dada. Proyecta cada cuerpo en ambos sentidos (directo y converso) y los +/// empareja contra los puntos natales —cuerpos y los cuatro ángulos—. +/// +/// Es la misma matemática que `bridge::build_primary_directions_overlay`, +/// pero sin construir el dual-ring de glifos: el rectificador sólo necesita +/// los triggers, no la capa visual. +fn gr_triggers_de_natal( + natal: &NatalChart, + edad_years: f64, + key: EDirectionKey, +) -> Vec { + let eps = natal.obliquity_rad; + + // Proyectar cada cuerpo natal por dirección primaria, en ambos sentidos. + let mut directed: Vec<(String, GrDirection, f32)> = Vec::new(); + for (gr_dir, pd_dir) in [ + (GrDirection::Direct, PrimaryDirection::Direct), + (GrDirection::Converse, PrimaryDirection::Converse), + ] { + for p in &natal.placements { + let lon_rad = directed_longitude( + p.right_ascension_rad, + p.declination_rad, + edad_years, + pd_dir, + key, + eps, + ); + let deg = (lon_rad.to_degrees() as f32).rem_euclid(360.0); + directed.push((body_symbol(p.body).to_string(), gr_dir, deg)); + } + } + + // Puntos natales objetivo: los cuerpos + los cuatro ángulos. + let mut natal_targets: Vec<(String, f32)> = natal + .placements + .iter() + .map(|p| { + ( + body_symbol(p.body).to_string(), + p.longitude.longitude_deg() as f32, + ) + }) + .collect(); + natal_targets.push(("asc".into(), natal.ascendant().longitude_deg() as f32)); + natal_targets.push(("mc".into(), natal.midheaven().longitude_deg() as f32)); + natal_targets.push(("desc".into(), natal.descendant().longitude_deg() as f32)); + natal_targets.push(("ic".into(), natal.imum_coeli().longitude_deg() as f32)); + + compute_gr_triggers( + &directed, + &natal_targets, + GR_HUD_ORB_DEG, + GR_EVENT_ORB_DEG, + GR_MAX_TRIGGERS, + ) +} + +/// Barre las horas candidatas y devuelve la rectificación. Ver +/// [`crate::rectificar`] para la documentación pública. +pub(crate) fn rectificar( + chart: &Chart, + eventos: &[EventoConocido], + ventana_min: i64, + paso_min: i64, + key_str: &str, +) -> Result { + if eventos.is_empty() { + return Err(EngineError::Eternal( + "rectificar: sin eventos conocidos que anclar la búsqueda".into(), + )); + } + let ventana = ventana_min.max(0); + let paso = paso_min.max(1); + let key = match key_str { + "ptolemy" => EDirectionKey::Ptolemy, + _ => EDirectionKey::Naibod, + }; + + // Barrer las horas candidatas: cada offset es una hora de nacimiento a + // probar, en minutos sobre la registrada. + let mut perfil: Vec<(i64, f32)> = Vec::new(); + let mut offset = -ventana; + while offset <= ventana { + // Una sola carta natal por hora candidata (cacheada en el bridge); + // la proyección por edad de evento es barata sobre ella. + let (natal, _, _) = compute_natal_chart(chart, offset)?; + let mut puntaje = 0.0_f32; + for evento in eventos { + let triggers = gr_triggers_de_natal(&natal, evento.edad_years, key); + // Menor orbe de convergencia = mejor explicación del evento; + // sin convergencia, la penalización. + puntaje += convergencia_minima(&triggers).unwrap_or(SIN_CONVERGENCIA); + } + perfil.push((offset, puntaje)); + offset += paso; + } + + // El mejor candidato: puntaje mínimo. Ante empate, el offset más + // cercano a 0 — la hora registrada se respeta si nada la mejora. + let (mejor_offset_minutos, mejor_puntaje) = perfil + .iter() + .copied() + .min_by(|(oa, pa), (ob, pb)| { + pa.partial_cmp(pb) + .unwrap_or(core::cmp::Ordering::Equal) + .then(oa.abs().cmp(&ob.abs())) + }) + .expect("el perfil tiene al menos un candidato — la ventana incluye el 0"); + + Ok(Rectificacion { + mejor_offset_minutos, + mejor_puntaje, + perfil, + }) +} diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/gr.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/gr.rs index 307ede2..f0b04bf 100644 --- a/crates/modules/cosmobiologia/cosmobiologia-render/src/gr.rs +++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/gr.rs @@ -154,6 +154,40 @@ fn mark_events(triggers: &mut [GrTrigger], event_orb_deg: f32) { } } +/// Orbe de la convergencia GR más cerrada de un conjunto de triggers +/// computado a una edad dada: por cada punto natal tocado a la vez por +/// un trigger directo y otro converso, la suma de los dos orbes; se +/// devuelve la MENOR de esas sumas. `None` si ningún punto natal recibe +/// ambas direcciones — no hubo convergencia. +/// +/// Es la medida **continua** de «qué tan bien una carta explica un +/// evento»: a diferencia del flag binario `event` (dentro o fuera del +/// micro-orbe), esta suma decrece de forma suave a medida que la hora +/// candidata acerca el directo y el converso al punto natal. El +/// rectificador automático la minimiza barriendo horas de nacimiento. +pub fn convergencia_minima(triggers: &[GrTrigger]) -> Option { + use std::collections::HashMap; + // Por punto natal: el mejor orbe directo y el mejor orbe converso. + let mut por_objetivo: HashMap<&str, (Option, Option)> = HashMap::new(); + for t in triggers { + let (directo, converso) = por_objetivo + .entry(t.natal_target.as_str()) + .or_insert((None, None)); + let ranura = match t.direction { + GrDirection::Direct => directo, + GrDirection::Converse => converso, + }; + *ranura = Some(ranura.map_or(t.orb_deg, |previo| previo.min(t.orb_deg))); + } + por_objetivo + .values() + .filter_map(|par| match par { + (Some(directo), Some(converso)) => Some(directo + converso), + _ => None, + }) + .reduce(f32::min) +} + #[cfg(test)] mod tests { use super::*; @@ -242,4 +276,45 @@ mod tests { assert_eq!(out.len(), 1); assert!((out[0].orb_deg - 2.0).abs() < 1e-3); } + + #[test] + fn convergencia_minima_suma_directo_y_converso() { + // Marte directo a 0.1° del Sol, Venus converso a 0.2°. + let directed = vec![ + d("mars", GrDirection::Direct, 100.1), + d("venus", GrDirection::Converse, 99.8), + ]; + let targets = vec![("sun".to_string(), 100.0)]; + let triggers = compute_gr_triggers(&directed, &targets, 2.0, 0.083, 60); + let conv = convergencia_minima(&triggers).expect("hay convergencia"); + assert!((conv - 0.3).abs() < 1e-3, "0.1 directo + 0.2 converso: {conv}"); + } + + #[test] + fn convergencia_minima_none_sin_ambas_direcciones() { + // Sólo toques directos sobre el Sol: no hay convergencia. + let directed = vec![ + d("mars", GrDirection::Direct, 100.1), + d("venus", GrDirection::Direct, 99.9), + ]; + let targets = vec![("sun".to_string(), 100.0)]; + let triggers = compute_gr_triggers(&directed, &targets, 2.0, 0.083, 60); + assert!(convergencia_minima(&triggers).is_none()); + } + + #[test] + fn convergencia_minima_elige_el_punto_natal_mas_cerrado() { + // Convergencia floja sobre el Sol (0.5+0.5), cerrada sobre la + // Luna (0.05+0.05): gana la de la Luna. + let directed = vec![ + d("mars", GrDirection::Direct, 100.5), + d("venus", GrDirection::Converse, 99.5), + d("jupiter", GrDirection::Direct, 200.05), + d("saturn", GrDirection::Converse, 199.95), + ]; + let targets = vec![("sun".to_string(), 100.0), ("moon".to_string(), 200.0)]; + let triggers = compute_gr_triggers(&directed, &targets, 2.0, 0.083, 60); + let conv = convergencia_minima(&triggers).expect("hay convergencia"); + assert!((conv - 0.1).abs() < 1e-3, "gana la Luna (0.05+0.05): {conv}"); + } } diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs index 5241581..b976048 100644 --- a/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs +++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs @@ -39,7 +39,7 @@ pub mod palette; pub use draw::{ compose_wheel, draw_commands_to_svg, CompositionOpts, DrawCommand, Rgba, TextAnchor, }; -pub use gr::{compute_gr_triggers, GrDirection, GrTrigger}; +pub use gr::{compute_gr_triggers, convergencia_minima, GrDirection, GrTrigger}; pub use harmonic::apply_harmonic; pub use math::{ find_clusters, format_coord_compact, polar_to_screen, spread_angles, Radii,