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,