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, 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 { 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 = 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 { - 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 { 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 { - 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)); - } +) -> 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 +} - // Puntos natales objetivo: los cuerpos + los cuatro ángulos. - let mut natal_targets: Vec<(String, f32)> = natal - .placements +/// 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, 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) +} + +/// 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 { 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, })