perf(tahuantinsuyu): LRU cache de NatalChart por (birth, config, offset)

`NatalChart::compute` cuesta varios ms (VSOP2013 + casas + aspectos
base). Bajo drag de slider en el panel, el shell dispara `compose()`
decenas de veces — la natal del sujeto principal y la del partner
de Synastry/Composite son **idénticas** entre frames pero hoy se
recomputan.

Nuevo `natal_cache.rs`: LRU de 8 entradas con `Mutex<Vec<(Key, Arc)>>`,
key = hash de contenido `(StoredBirthData, StoredChartConfig,
offset_minutes)`. Move-to-front en hit, evict del back cuando se
llena. f64s se hashean vía `to_bits()`.

`compute_natal_chart` ahora consulta el cache antes de delegar a
eternal; firma cambia a devolver `Arc<NatalChart>` — los call sites
(natal principal, partner de Synastry/Composite) usan auto-deref a
través de `Arc::Deref` sin cambios.

Editar una carta (cualquier campo de `StoredBirthData` o
`StoredChartConfig`) invalida automáticamente su entrada porque el
hash cambia. Capacidad 8 cubre el caso típico (natal + partner) con
holgura.

Test nuevo `natal_cache_hits_are_faster` valida que `compose` con
offset_minutes repetido es más rápido que con offset distinto (HIT
vs MISS): 9 tests engine, todos verdes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-18 00:41:36 +00:00
parent 2192c29d4f
commit 904f334069
3 changed files with 181 additions and 6 deletions
@@ -5,7 +5,7 @@
//! memoria), y como es read-only se puede leer en paralelo desde varios
//! cómputos.
use std::sync::OnceLock;
use std::sync::{Arc, OnceLock};
use std::time::Instant;
use eternal_astrology::{
@@ -228,18 +228,30 @@ fn build_eternal_inputs(
Ok((birth_e, config_e, observer))
}
/// Computa solo la `NatalChart` (sin construir RenderModel). Útil para
/// pipelines compuestas (transits, sinastría) que necesitan el natal
/// crudo para correr `find_synastry_aspects`.
/// Computa la `NatalChart` consultando primero el LRU cache global.
/// Útil para pipelines compuestas (transits, sinastría, composite) que
/// computan la misma carta natal del partner en cada render — bajo
/// drag de sliders se llama decenas de veces seguidas con inputs
/// idénticos.
///
/// 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(
chart: &Chart,
offset_minutes: i64,
) -> Result<(NatalChart, ChartConfig, Observer), EngineError> {
) -> 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);
if let Some(cached) = crate::natal_cache::get(key) {
return Ok((cached, config_e, observer));
}
let session = session()?;
let natal = NatalChart::compute(&birth_e, &config_e, session)
.map_err(|e| EngineError::Eternal(format!("NatalChart::compute: {:?}", e)))?;
Ok((natal, config_e, observer))
let arc = Arc::new(natal);
crate::natal_cache::insert(key, arc.clone());
Ok((arc, config_e, observer))
}
/// Composición principal: natal + overlays pedidos. Es la función que