gioser

tawasuyu/brahman

Rust 570TOML 215Markdown 68JSON 198261 Definiciones

feat(cosmobiologia): rectificador automático — escaneo GR (núcleo)

Primer incremento del rectificador automático (#67): dado un conjunto
de eventos conocidos de la vida del sujeto, barre las horas de
nacimiento candidatas y devuelve la que mejor los explica vía el
Sistema GR. La killer feature pro — desbloqueada al completar el GR.

- cosmobiologia-render: `convergencia_minima` — medida CONTINUA de qué
  tan bien una carta explica un evento (suma de orbes del directo +
  converso más cerrados sobre un punto natal). 3 tests.
- cosmobiologia-engine: módulo `rectify` — `rectificar` barre la
  ventana de horas candidatas; por candidata computa la carta (una
  vez, cacheada) y mide la convergencia GR a la edad de cada evento;
  elige el puntaje mínimo. Devuelve el perfil completo del barrido
  para que la UI lo dibuje como curva. Test end-to-end con eternal.
- bridge: `compute_natal_chart`/`body_symbol`/consts GR → pub(crate).

Falta: la UI (capturar eventos conocidos, lanzar el barrido, mostrar
la curva y la hora rectificada).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

0ada1050f7ea45109d7ce6d97c37c8c3344eb0e3 · por sergio · (hace 5 meses) · padre bce4abd8c · ver el árbol en este commit

Delta semántico · 17

crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs

crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs

crates/modules/cosmobiologia/cosmobiologia-engine/src/rectify.rs

5 archivos · +319 −9

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<Body> {
     })
 }
 
-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<NatalChart>, 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<Rectificacion, EngineError> {
+    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<GrTrigger> {
+    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<Rectificacion, EngineError> {
+    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<f32> {
+    use std::collections::HashMap;
+    // Por punto natal: el mejor orbe directo y el mejor orbe converso.
+    let mut por_objetivo: HashMap<&str, (Option<f32>, Option<f32>)> = 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,