gioser

tawasuyu/brahman

Rust 570TOML 215Markdown 68JSON 198261 Definiciones

feat(cosmobiologia): rectificador per-segundo + direcciones primarias reales

El rectificador deja la aproximación y pasa a la trigonometría exacta,
con precisión de segundo — el "microajuste argentino".

LA MATEMÁTICA. El rectificador ya NO usa el modelo simplificado
(directed_longitude, rotación uniforme de RA + convergencia GR). Ahora
usa `eternal_astrology::primary_direction::all_directions` — el método
Placidus-mundano: semi-arcos diurnos/nocturnos bajo el polo de cada
cuerpo, la trigonometría esférica de la escuela ascensional. No se
reimplementó nada: la matemática, ya probada, vive en eternal; el
engine sólo aporta la capa de optimización.

- error_de_carta: por cada evento, la distancia en años a la dirección
  primaria que perfecciona más cerca; el error total es la suma. Es la
  función de coste del microajuste — el valle es la hora real.

PRECISIÓN DE SEGUNDO. compute_natal_chart / build_eternal_inputs /
natal_cache pasan a trabajar en SEGUNDOS (compose convierte ×60). El
rectificador barre en dos pasadas: gruesa minuto a minuto sobre la
ventana (el perfil que dibuja la curva), fina segundo a segundo en
±60 s alrededor del mejor minuto.

- Rectificacion: mejor_offset_segundos; el perfil va en segundos.
- UI: panel y curva muestran «±Xm Ys · error N.NNa». Las barras siguen
  siendo clicables (scrub a esa hora candidata).

Tests verdes (engine 12, render 28). Limitación conocida: all_directions
es sólo directo — converso necesita crecer en eternal (upstream).

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

36d6645e7fe3fbb68f45352948429d20eaa4c86d · por sergio · (hace 5 meses) · padre 5fdae159f · ver el árbol en este commit

Delta semántico · 17

crates/apps/cosmobiologia/src/shell.rs

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

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

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

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

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

6 archivos · +164 −166

diff --git a/crates/apps/cosmobiologia/src/shell.rs b/crates/apps/cosmobiologia/src/shell.rs
index 3db279c..f6d42c2 100644
--- a/crates/apps/cosmobiologia/src/shell.rs
+++ b/crates/apps/cosmobiologia/src/shell.rs
@@ -1410,12 +1410,18 @@ impl Shell {
             .unwrap_or("naibod")
             .to_string();
 
-        // Ventana ±15 min, paso 1 min — el barrido GR estándar.
-        match cosmobiologia_engine::rectificar(&chart, &eventos, 15, 1, &key_gr) {
+        // Ventana ±15 min — dos pasadas (minuto grueso, segundo fino).
+        match cosmobiologia_engine::rectificar(&chart, &eventos, 15, &key_gr) {
             Ok(r) => {
+                // Offset en segundos → texto «±Xm Ys».
+                let seg = r.mejor_offset_segundos;
+                let signo = if seg < 0 { "-" } else { "+" };
+                let abs = seg.abs();
                 let resumen = format!(
-                    "{:+} min · puntaje {:.2}",
-                    r.mejor_offset_minutos, r.mejor_puntaje
+                    "{signo}{}m {:02}s · error {:.2}a",
+                    abs / 60,
+                    abs % 60,
+                    r.mejor_puntaje
                 );
                 self.panel.update(cx, |p, cx| {
                     p.set_string("primary_directions", "resultado", Some(resumen), cx)
diff --git a/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs
index 026a224..4c11c18 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs
@@ -2064,24 +2064,28 @@ fn render_rectify_profile(
     let rango = (max_p - min_p).max(1e-3);
     let primero = r.perfil.first().map(|&(o, _)| o).unwrap_or(0);
     let ultimo = r.perfil.last().map(|&(o, _)| o).unwrap_or(0);
+    // El perfil va en segundos a paso de minuto; el mejor offset es
+    // fino (segundos). La barra resaltada es la del minuto más cercano.
+    let mejor_barra = (r.mejor_offset_segundos as f64 / 60.0).round() as i64 * 60;
 
     let mut bars = div().flex().flex_row().items_end().gap(px(2.0));
     for &(offset, puntaje) in &r.perfil {
         // Fitness: el mejor candidato (puntaje mínimo) → barra más alta.
         let fitness = ((max_p - puntaje) / rango).clamp(0.0, 1.0);
         let bar_h = (fitness * BAR_AREA_H).max(2.0);
-        let es_mejor = offset == r.mejor_offset_minutos;
+        let es_mejor = offset == mejor_barra;
         let color = if es_mejor {
             palette.angle_highlight
         } else {
             with_alpha(palette.angle_highlight, 0.25 + fitness * 0.45)
         };
         // Etiquetar sólo los hitos: el mejor, el 0 y los dos extremos.
+        // El offset va en segundos; la etiqueta lo muestra en minutos.
         let label = if es_mejor || offset == 0 || offset == primero || offset == ultimo {
             if offset == 0 {
                 "0".to_string()
             } else {
-                format!("{offset:+}")
+                format!("{:+}", offset / 60)
             }
         } else {
             String::new()
@@ -2113,17 +2117,23 @@ fn render_rectify_profile(
             )
             .on_click({
                 // Un clic lleva la carta a esta hora candidata reusando
-                // el scrub de tiempo del jog-dial (`TimeOffsetChanged`).
+                // el scrub de tiempo del jog-dial (`TimeOffsetChanged`,
+                // en minutos — el offset del perfil va en segundos).
                 let entity = entity.clone();
                 move |_: &gpui::ClickEvent, _w, cx: &mut gpui::App| {
                     entity.update(cx, |_this, cx| {
-                        cx.emit(CanvasEvent::TimeOffsetChanged(offset));
+                        cx.emit(CanvasEvent::TimeOffsetChanged(offset / 60));
                     });
                 }
             });
         bars = bars.child(column);
     }
 
+    // La hora rectificada, fina: «±Xm Ys».
+    let seg = r.mejor_offset_segundos;
+    let signo = if seg < 0 { "-" } else { "+" };
+    let abs = seg.abs();
+
     div()
         .flex()
         .flex_col()
@@ -2134,8 +2144,11 @@ fn render_rectify_profile(
                 .text_size(px(10.0))
                 .text_color(theme.fg_muted)
                 .child(SharedString::from(format!(
-                    "Rectificación · hora {:+} min · puntaje {:.2} · el valle es la hora",
-                    r.mejor_offset_minutos, r.mejor_puntaje
+                    "Rectificación · hora {}{}m {:02}s · error {:.2}a · el valle es la hora",
+                    signo,
+                    abs / 60,
+                    abs % 60,
+                    r.mejor_puntaje
                 ))),
         )
         .child(bars)
diff --git a/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs b/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs
index 2f2642d..8743e60 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs
@@ -186,7 +186,7 @@ fn aspect_kind_id(k: EAspectKind) -> &'static str {
 /// (transits, sinastría) sin re-traducir.
 fn build_eternal_inputs(
     chart: &Chart,
-    offset_minutes: i64,
+    offset_seconds: i64,
 ) -> Result<(BirthData, ChartConfig, Observer), EngineError> {
     chart.validate()?;
     let bd = &chart.birth_data;
@@ -209,10 +209,12 @@ fn build_eternal_inputs(
     )
     .map_err(|e| EngineError::Eternal(format!("Instant::from_civil_local: {:?}", e)))?;
 
-    let instant = if offset_minutes == 0 {
+    // Microajuste temporal en SEGUNDOS — el rectificador automático
+    // barre la hora candidata con resolución de segundo.
+    let instant = if offset_seconds == 0 {
         base_instant
     } else {
-        let shifted_utc = base_instant.utc().add_seconds((offset_minutes as f64) * 60.0);
+        let shifted_utc = base_instant.utc().add_seconds(offset_seconds as f64);
         ESInstant::from_utc(shifted_utc)
     };
 
@@ -241,10 +243,10 @@ fn build_eternal_inputs(
 /// automáticamente la entrada.
 pub(crate) fn compute_natal_chart(
     chart: &Chart,
-    offset_minutes: i64,
+    offset_seconds: i64,
 ) -> Result<(Arc<NatalChart>, ChartConfig, Observer), EngineError> {
-    let (birth_e, config_e, observer) = build_eternal_inputs(chart, offset_minutes)?;
-    let key = crate::natal_cache::key_for(&chart.birth_data, &chart.config, offset_minutes);
+    let (birth_e, config_e, observer) = build_eternal_inputs(chart, offset_seconds)?;
+    let key = crate::natal_cache::key_for(&chart.birth_data, &chart.config, offset_seconds);
     if let Some(cached) = crate::natal_cache::get(key) {
         return Ok((cached, config_e, observer));
     }
@@ -265,7 +267,9 @@ pub fn compose(
     natal_options: &crate::NatalOptions,
 ) -> Result<RenderModel, EngineError> {
     let t0 = Instant::now();
-    let (natal, config_e, observer) = compute_natal_chart(chart, offset_minutes)?;
+    // `compute_natal_chart` trabaja en segundos; `compose` recibe el
+    // offset en minutos (el scrub del jog-dial, la API pública).
+    let (natal, config_e, observer) = compute_natal_chart(chart, offset_minutes * 60)?;
     let orb_table = build_orb_table(natal_options.orb_multiplier);
     let all_aspects = find_aspects(&natal, &orb_table);
     let aspects: Vec<Aspect> = all_aspects
diff --git a/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs
index 5c3a8b3..1db0aed 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs
@@ -213,12 +213,12 @@ impl Default for NatalOptions {
 }
 
 // =====================================================================
-// Rectificador automático (Sistema GR)
+// Rectificador automático (direcciones primarias)
 // =====================================================================
 
 /// 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.
+/// los eventos reales sobre la perfección de una dirección primaria.
 #[derive(Debug, Clone, Copy)]
 pub struct EventoConocido {
     /// Edad del sujeto, en años, cuando ocurrió el evento.
@@ -228,23 +228,25 @@ pub struct EventoConocido {
 /// 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.
+    /// Desplazamiento, en **segundos**, sobre la hora registrada, que
+    /// mejor explica los eventos. `0` = la hora registrada ya es la
+    /// mejor. La precisión de segundo viene de la pasada fina.
+    pub mejor_offset_segundos: i64,
+    /// Error del mejor candidato: la suma, en años, del desajuste entre
+    /// cada evento y su dirección primaria más cercana. Menor = mejor.
     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.
+    /// El barrido grueso: `(offset_segundos, error)` por candidato a
+    /// resolución de minuto, ordenado por offset. La UI lo dibuja como
+    /// 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.
+/// Rectifica la hora de nacimiento por direcciones primarias. Barre las
+/// horas candidatas en `±ventana_min` minutos sobre la registrada —una
+/// pasada gruesa por minuto y una fina por segundo alrededor del mejor
+/// minuto— y, por cada candidata, mide el desajuste entre los eventos
+/// conocidos y los arcos de dirección primaria (método Placidus-mundano
+/// de `eternal-astrology`). La hora del error 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.
@@ -253,10 +255,9 @@ 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)
+    rectify::rectificar(chart, eventos, ventana_min, key)
 }
 
 /// Composición canónica: carta natal + todos los overlays pedidos.
@@ -585,8 +586,8 @@ mod tests {
         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.
+    /// El rectificador barre la ventana en dos pasadas, devuelve un
+    /// perfil grueso ordenado y un offset fino de resolución de segundo.
     #[cfg(feature = "eternal-bridge")]
     #[test]
     fn rectificar_barre_la_ventana_y_elige_el_minimo() {
@@ -595,21 +596,20 @@ mod tests {
             EventoConocido { edad_years: 20.0 },
             EventoConocido { edad_years: 35.0 },
         ];
-        let r = rectificar(&chart, &eventos, 10, 2, "naibod").expect("rectificar");
+        let r = rectificar(&chart, &eventos, 10, "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.
+        // Pasada gruesa: ±10 min a paso de minuto → 21 candidatos.
+        assert_eq!(r.perfil.len(), 21);
+        // El perfil (en segundos) 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);
+        // El mejor offset cae dentro de la ventana + el margen de la
+        // pasada fina (±60 s).
+        assert!(r.mejor_offset_segundos.abs() <= 10 * 60 + 60);
+        assert!(r.mejor_puntaje >= 0.0);
 
         // Sin eventos no hay ancla — debe ser un error.
-        assert!(rectificar(&chart, &[], 10, 2, "naibod").is_err());
+        assert!(rectificar(&chart, &[], 10, "naibod").is_err());
     }
 }
diff --git a/crates/modules/cosmobiologia/cosmobiologia-engine/src/natal_cache.rs b/crates/modules/cosmobiologia/cosmobiologia-engine/src/natal_cache.rs
index aca2a8d..c67db13 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-engine/src/natal_cache.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-engine/src/natal_cache.rs
@@ -9,7 +9,7 @@
 //! Este cache de 8 entradas es suficiente: el usuario rara vez tiene
 //! más de 2 cartas activas a la vez (natal + partner) y el LRU bota la
 //! más vieja cuando se llena. La clave es el **contenido** de
-//! `StoredBirthData + StoredChartConfig + offset_minutes`, así que
+//! `StoredBirthData + StoredChartConfig + offset_seconds`, así que
 //! editar una carta invalida automáticamente su entrada.
 
 use std::collections::hash_map::DefaultHasher;
@@ -70,7 +70,7 @@ fn cache() -> &'static Mutex<Cache> {
 pub fn key_for(
     birth: &StoredBirthData,
     config: &StoredChartConfig,
-    offset_minutes: i64,
+    offset_seconds: i64,
 ) -> u64 {
     let mut h = DefaultHasher::new();
     // Birth data — fecha/hora/lugar.
@@ -95,8 +95,8 @@ pub fn key_for(
     config.include_lilith.hash(&mut h);
     config.include_main_belt_asteroids.hash(&mut h);
     config.include_fixed_stars.hash(&mut h);
-    // Offset temporal (rectificación rápida).
-    offset_minutes.hash(&mut h);
+    // Offset temporal en segundos (microajuste de rectificación).
+    offset_seconds.hash(&mut h);
     h.finish()
 }
 
diff --git a/crates/modules/cosmobiologia/cosmobiologia-engine/src/rectify.rs b/crates/modules/cosmobiologia/cosmobiologia-engine/src/rectify.rs
index efbf4fd..c3a0cff 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-engine/src/rectify.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-engine/src/rectify.rs
@@ -1,101 +1,99 @@
-//! Rectificador automático — Sistema GR.
+//! Rectificador automático — microajuste por direcciones primarias.
 //!
 //! 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.
+//! nacimiento registrada es incierta, ¿cuál es la verdadera? El método
+//! ascensional la ataca con direcciones primarias: en la hora correcta,
+//! los eventos reales de la vida del sujeto **coinciden** con la
+//! perfección de una dirección primaria — el arco que la esfera celeste
+//! rota tras el nacimiento hasta que un promisor alcanza la posición
+//! mundana de un significador.
 //!
-//! 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.
+//! La trigonometría esférica de esos arcos —el método Placidus-mundano,
+//! semi-arcos diurnos/nocturnos bajo el polo de cada cuerpo— **no se
+//! reimplementa aquí**: la aporta, ya probada, `eternal-astrology`
+//! (`primary_direction::all_directions`). Este módulo es la capa de
+//! OPTIMIZACIÓN: barre las horas candidatas y minimiza el desajuste
+//! entre los eventos conocidos y los arcos teóricos.
 //!
-//! 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`.
+//! El barrido es de **dos pasadas**: una gruesa, minuto a minuto sobre
+//! toda la ventana (el perfil que la UI dibuja como curva), y una fina,
+//! segundo a segundo alrededor del mejor minuto — de ahí la precisión
+//! de segundo del microajuste.
 
-use eternal_astrology::{
-    directed_longitude, primary_direction::PrimaryDirection, DirectionKey as EDirectionKey,
-    NatalChart,
-};
+use eternal_astrology::primary_direction::{all_directions, DirectionMethod};
+use eternal_astrology::{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,
-};
+use crate::bridge::compute_natal_chart;
+use crate::{Chart, EngineError, EventoConocido, 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;
+/// Edad máxima (años) hasta la que se computan direcciones primarias —
+/// cubre con holgura cualquier evento de una vida humana.
+const EDAD_MAX: f64 = 100.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(
+/// Penalización (años) que se imputa a un evento cuando ninguna
+/// dirección primaria cae cerca. Mayor que cualquier desajuste real
+/// plausible: un candidato sin dirección queda inequívocamente peor.
+const SIN_DIRECCION: f32 = 20.0;
+
+/// Error de una carta candidata frente a los eventos conocidos: por
+/// cada evento, la distancia en años a la dirección primaria más
+/// cercana; el error total es la suma. Es la función de coste del
+/// microajuste — el segundo de nacimiento correcto la lleva a un valle.
+fn error_de_carta(
     natal: &NatalChart,
-    edad_years: f64,
+    eventos: &[EventoConocido],
     key: EDirectionKey,
-) -> Vec<GrTrigger> {
-    let eps = natal.obliquity_rad;
+) -> f32 {
+    // Todas las direcciones primarias (Placidus-mundano) y la edad a la
+    // que cada una perfecciona. La matemática esférica vive en eternal.
+    let dirs = all_directions(natal, DirectionMethod::PlacidusMundane, key, EDAD_MAX);
+    let mut total = 0.0_f32;
+    for evento in eventos {
+        // La dirección cuya perfección cae más cerca de la edad del
+        // evento: en la hora correcta, esa distancia tiende a cero.
+        let cercania = dirs
+            .iter()
+            .map(|d| (evento.edad_years - d.age_years).abs() as f32)
+            .reduce(f32::min)
+            .unwrap_or(SIN_DIRECCION);
+        total += cercania.min(SIN_DIRECCION);
+    }
+    total
+}
 
-    // 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));
-        }
+/// Barre los offsets de `[desde, hasta]` segundos con paso `paso` y
+/// devuelve `(offset_segundos, error)` por candidato.
+fn barrer(
+    chart: &Chart,
+    eventos: &[EventoConocido],
+    key: EDirectionKey,
+    desde: i64,
+    hasta: i64,
+    paso: i64,
+) -> Result<Vec<(i64, f32)>, EngineError> {
+    let mut perfil = Vec::new();
+    let mut offset = desde;
+    while offset <= hasta {
+        // Una carta natal por hora candidata (cacheada en el bridge).
+        let (natal, _, _) = compute_natal_chart(chart, offset)?;
+        perfil.push((offset, error_de_carta(&natal, eventos, key)));
+        offset += paso;
     }
+    Ok(perfil)
+}
 
-    // Puntos natales objetivo: los cuerpos + los cuatro ángulos.
-    let mut natal_targets: Vec<(String, f32)> = natal
-        .placements
+/// El candidato de menor error. Ante empate, el offset más cercano a 0
+/// — la hora registrada se respeta si nada la mejora.
+fn mejor_de(perfil: &[(i64, f32)]) -> (i64, f32) {
+    perfil
         .iter()
-        .map(|p| {
-            (
-                body_symbol(p.body).to_string(),
-                p.longitude.longitude_deg() as f32,
-            )
+        .copied()
+        .min_by(|(oa, pa), (ob, pb)| {
+            pa.partial_cmp(pb)
+                .unwrap_or(core::cmp::Ordering::Equal)
+                .then(oa.abs().cmp(&ob.abs()))
         })
-        .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,
-    )
+        .unwrap_or((0, 0.0))
 }
 
 /// Barre las horas candidatas y devuelve la rectificación. Ver
@@ -104,7 +102,6 @@ pub(crate) fn rectificar(
     chart: &Chart,
     eventos: &[EventoConocido],
     ventana_min: i64,
-    paso_min: i64,
     key_str: &str,
 ) -> Result<Rectificacion, EngineError> {
     if eventos.is_empty() {
@@ -112,46 +109,24 @@ pub(crate) fn rectificar(
             "rectificar: sin eventos conocidos que anclar la búsqueda".into(),
         ));
     }
-    let ventana = ventana_min.max(0);
-    let paso = paso_min.max(1);
+    let ventana = ventana_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;
-    }
+    // PASADA 1 — gruesa, minuto a minuto sobre toda la ventana. Es el
+    // perfil que la UI dibuja como curva: el valle salta a la vista.
+    let perfil = barrer(chart, eventos, key, -ventana * 60, ventana * 60, 60)?;
+    let (mejor_minuto, _) = mejor_de(&perfil);
 
-    // 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");
+    // PASADA 2 — fina, segundo a segundo en ±60 s alrededor del mejor
+    // minuto. Aquí nace la precisión de segundo del microajuste.
+    let fino = barrer(chart, eventos, key, mejor_minuto - 60, mejor_minuto + 60, 1)?;
+    let (mejor_offset_segundos, mejor_puntaje) = mejor_de(&fino);
 
     Ok(Rectificacion {
-        mejor_offset_minutos,
+        mejor_offset_segundos,
         mejor_puntaje,
         perfil,
     })