tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Definiciones

⎇ a92fa1577700a5cb28c6bb96872300ce99a7e5f8 crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs Rust · 595 líneas · 21.7 KB Historia Quién tocó qué crudo

feat(tahuantinsuyu): anti-solapamiento de glyphs + selector Naibod/Ptolomeo a92fa1577 · sergio ·

1//! `tahuantinsuyu-engine` — bridge entre el modelo agnóstico y
2//! `eternal-astrology`.
3//!
4//! Recibe un `Chart` del modelo + un `ChartKind` y devuelve un
5//! [`RenderModel`] que describe la geometría a pintar **sin** acoplar
6//! el canvas a tipos de la librería astronómica. El canvas habla
7//! grados decimales, radios normalizados y kinds simbólicos.
8//!
9//! ## Por qué un RenderModel intermedio
10//!
11//! 1. El canvas no debería caer si cambia el shape de `NatalChart`
12//!    upstream.
13//! 2. Tests del canvas: podemos generar `RenderModel`s sintéticos sin
14//!    arrancar eternal.
15//! 3. Cada `ChartKind` produce el mismo shape genérico → el render
16//!    coordina N módulos sin saber qué calcularon.
17//!
18//! ## Feature `eternal-bridge`
19//!
20//! - **on** (default): [`compute`] abre una `EphemerisSession` VSOP2013
21//!   compartida y corre la pipeline real.
22//! - **off**: [`compute`] cae a [`compute_mock`] — útil para tests +
23//!   builds sin eternal checked out.
24
25#![forbid(unsafe_code)]
26#![warn(rust_2018_idioms)]
27
28use serde::{Deserialize, Serialize};
29use thiserror::Error;
30
31pub use tahuantinsuyu_model::{Chart, ChartId, ChartKind};
32
33// `Chart` reexportado arriba es lo que `PipelineRequest::Synastry`
34// transporta — el caller (shell) lee del Store y pasa el Chart entero
35// para que el bridge construya su NatalChart en eternal.
36
37#[cfg(feature = "eternal-bridge")]
38mod bridge;
39#[cfg(feature = "eternal-bridge")]
40mod dignity;
41#[cfg(feature = "eternal-bridge")]
42mod natal_cache;
43#[cfg(feature = "eternal-bridge")]
44pub mod svg_export;
45
46// =====================================================================
47// RenderModel — lo que el canvas necesita pintar
48// =====================================================================
49
50/// Resultado agnóstico de un cómputo astrológico, listo para renderizar.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct RenderModel {
53    pub chart_id: ChartId,
54    pub chart_kind: ChartKind,
55    pub title: String,
56    #[serde(default)]
57    pub subtitle: Option<String>,
58    pub compute_ms: u64,
59
60    // ─── Ángulos del chart (grados eclípticos, 0..360) ───────────────
61    /// Ascendente — punto fijo de rotación del lienzo. La rueda se gira
62    /// de modo que el Asc cae a las 9 (lado izquierdo).
63    pub ascendant_deg: f32,
64    pub midheaven_deg: f32,
65    pub descendant_deg: f32,
66    pub imum_coeli_deg: f32,
67
68    /// Capas a pintar. Orden = z-order ascendente.
69    pub layers: Vec<Layer>,
70    /// Metadata humana por overlay activo (transit, progresión,
71    /// sinastría, retorno...). Vacío para una carta natal pura. La UI
72    /// la pinta como badges en el footer.
73    #[serde(default)]
74    pub overlays: Vec<OverlayMeta>,
75    /// Lista paralela a las LineSeg de aspectos — uno por aspecto
76    /// natal o cross. Ordenado por `orb_deg` ascendente (los más
77    /// cerrados primero). La UI lo usa para la lista textual.
78    #[serde(default)]
79    pub aspect_summary: Vec<AspectSummary>,
80    /// Grupos uranianos detectados (cuerpos en la misma posición mod 90).
81    /// Vacío sino se activó el módulo Uranian.
82    #[serde(default)]
83    pub uranian_groups: Vec<UranianGroup>,
84}
85
86/// Etiqueta legible de un overlay para el footer del canvas. La engine
87/// la pushea desde cada `build_*_overlay`; el canvas solo lee y pinta.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct OverlayMeta {
90    pub module_id: String,
91    /// Etiqueta corta — ej. "Tránsito ahora", "Progresión 38.2a",
92    /// "Sinastría · Ana", "Saturn return 29a".
93    pub label: String,
94}
95
96/// Grupo de cuerpos natales que caen en la misma posición del
97/// dial uraniano de 90° (su longitud zodiacal módulo 90 es igual o
98/// muy cercana). En la astrología uraniana esto es una "fórmula" o
99/// "axis" — los cuerpos están en correspondencia simbólica directa
100/// porque comparten un cuadrante simétrico.
101///
102/// Solo se emiten grupos con 2+ miembros (los singletons no son
103/// fórmulas). La engine los ordena por proximidad al ε de tolerancia.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct UranianGroup {
106    /// Identificadores agnósticos de los cuerpos en el grupo
107    /// (ej. `["sun", "jupiter", "saturn"]`).
108    pub bodies: Vec<String>,
109    /// Posición en el dial de 90° (la longitud módulo 90).
110    pub mod90_deg: f64,
111}
112
113/// Resumen textual de un aspecto para listas legibles. La engine lo
114/// emite en paralelo con las `LineSeg` de la capa de aspectos, así
115/// el canvas no tiene que re-derivar nombres de cuerpos desde grados.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct AspectSummary {
118    /// Module al que pertenece — "natal", "transit", "synastry",
119    /// "progression", "solar_arc", "planetary_return".
120    pub module_id: String,
121    /// Identificador agnóstico del cuerpo "a" — "sun", "moon", etc.
122    pub from_body: String,
123    pub to_body: String,
124    /// Identificador del aspecto — "conjunction", "trine", etc.
125    pub kind: String,
126    pub orb_deg: f64,
127    /// `Some(true)` = applying, `Some(false)` = separating. `None` para
128    /// cross-aspects (sinastría/return) donde no se computa.
129    #[serde(default)]
130    pub applying: Option<bool>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct Layer {
135    pub module_id: String,
136    pub kind: LayerKind,
137    /// Radio normalizado [0, 1] sobre el lienzo — el canvas lo convierte
138    /// a píxeles. Permite stack de anillos.
139    pub ring: f32,
140    #[serde(default)]
141    pub z: i32,
142    pub geometry: Geometry,
143    #[serde(default)]
144    pub glyphs: Vec<Glyph>,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
148#[serde(rename_all = "snake_case")]
149pub enum LayerKind {
150    SignDial,
151    Houses,
152    Bodies,
153    Aspects,
154    Lots,
155    FixedStars,
156    Midpoints,
157    Outer,
158    Custom,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub enum Geometry {
163    GlyphsOnly,
164    /// Anillo dividido en sectores. `cusps_deg` son los grados
165    /// zodiacales donde van las divisiones radiales.
166    Ring { cusps_deg: Vec<f32> },
167    Lines(Vec<LineSeg>),
168    Points(Vec<PointMark>),
169}
170
171#[derive(Debug, Clone, Default, Serialize, Deserialize)]
172pub struct LineSeg {
173    /// Grados zodiacales del extremo "a".
174    pub from_deg: f32,
175    /// Grados zodiacales del extremo "b".
176    pub to_deg: f32,
177    /// Categoría simbólica (`"conjunction"`, `"trine"`, …) — el theme la
178    /// resuelve a color.
179    pub kind: String,
180    pub opacity: f32,
181    /// Cuerpo en el extremo "a" — populado para LineSegs de aspectos
182    /// (natal × natal, cross con overlays). Vacío en `Default::default`
183    /// para serde back-compat.
184    #[serde(default)]
185    pub from_body: String,
186    /// Cuerpo en el extremo "b".
187    #[serde(default)]
188    pub to_body: String,
189    /// Orb absoluto en grados (para tooltips).
190    #[serde(default)]
191    pub orb_deg: f32,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct PointMark {
196    pub deg: f32,
197    pub label: String,
198    pub tag: String,
199}
200
201#[derive(Debug, Clone, Default, Serialize, Deserialize)]
202pub struct Glyph {
203    /// Grado eclíptico [0, 360).
204    pub deg: f32,
205    /// Glyph simbólico — el theme/canvas lo mapea a unicode o imagen.
206    /// Ej: `"sun"`, `"moon"`, `"aries"`, `"asc"`, `"mc"`.
207    pub symbol: String,
208    #[serde(default)]
209    pub annotation: Option<String>,
210    #[serde(default)]
211    pub retrograde: bool,
212    #[serde(default)]
213    pub house: Option<u8>,
214    /// Marker de dignidad esencial, set solo cuando
215    /// `NatalOptions::show_dignities` está activo: `"+"` (domicilio),
216    /// `"·"` (exaltación), `"−"` (exilio), `"*"` (caída).
217    #[serde(default)]
218    pub dignity_marker: Option<String>,
219}
220
221// =====================================================================
222// Errores
223// =====================================================================
224
225#[derive(Debug, Error)]
226pub enum EngineError {
227    #[error("bridge a eternal-astrology no disponible (recompilá con feature `eternal-bridge`)")]
228    BridgeDisabled,
229    #[error("model: {0}")]
230    Model(#[from] tahuantinsuyu_model::ModelError),
231    #[error("eternal: {0}")]
232    Eternal(String),
233    #[error("kind {0:?} todavía no implementado")]
234    UnsupportedKind(ChartKind),
235}
236
237// =====================================================================
238// API pública
239// =====================================================================
240
241/// Pedidos que el host (Shell) eleva a la engine para componer un
242/// `RenderModel`. La capa natal **siempre** se computa; estos requests
243/// son **overlays adicionales**.
244///
245/// Cada variante mapea 1-a-1 con un Module declarado en
246/// `tahuantinsuyu-modules` por id string. Esto deja la engine como
247/// dueña única del cómputo (no depende del trait Module — los módulos
248/// son sólo metadata + UI controls).
249/// Módulos overlay que pintan en el mismo slot (outer ring del wheel)
250/// y por lo tanto son **mutuamente excluyentes** a nivel de UI: al
251/// prender uno, el shell debe apagar los otros. Single source of truth
252/// — el shell y el canvas leen de acá en vez de hardcodear listas.
253pub const OUTER_RING_MODULES: &[&str] = &["transit", "synastry", "planetary_return"];
254
255#[derive(Debug, Clone)]
256pub enum PipelineRequest {
257    /// `module_id = "transit"` — anillo externo con planetas al
258    /// instante actual (reloj de pared) + cross aspects natal × transit.
259    Transit,
260    /// `module_id = "progression"` — anillo interno con los planetas
261    /// progresados (método secundario "día por año") a la edad pedida
262    /// + cross aspects natal × progresada.
263    SecondaryProgression {
264        /// Edad simbólica en años a la que avanzar la carta. Para "la
265        /// edad de hoy", el shell la calcula a partir de `birth_data` +
266        /// `SystemTime::now`.
267        target_age_years: f64,
268    },
269    /// `module_id = "solar_arc"` — Solar Arc dirigido (default = "true
270    /// progressed Sun"): cada cuerpo y cada cusp natal se desplazan por
271    /// el mismo arco ≈ 1° por año de vida. Anillo interno bien adentro
272    /// + cross aspects natal × dirigida.
273    SolarArc {
274        target_age_years: f64,
275    },
276    /// `module_id = "synastry"` — bi-wheel: la natal en el centro, la
277    /// carta del partner en el anillo externo (compartido con Transit
278    /// — mutuamente excluyentes), cross aspects natal × partner.
279    /// El partner viene como `Chart` completo del shell.
280    Synastry {
281        partner_chart: Box<Chart>,
282    },
283    /// `module_id = "planetary_return"` — carta natal fresca al
284    /// instante del próximo retorno del cuerpo elegido a su posición
285    /// natal, para la edad pedida. Sun = retorno solar anual, Moon =
286    /// mensual, Júpiter/Saturno = generacionales. Anillo externo
287    /// compartido con Transit/Synastry — mutuamente excluyentes a
288    /// nivel de Shell.
289    PlanetaryReturn {
290        /// Identificador agnóstico del cuerpo ("sun", "moon",
291        /// "jupiter", …). El bridge lo mapea a `eternal_sky::Body`.
292        body: String,
293        target_age_years: f64,
294        /// Días extra que se suman al anchor de búsqueda (birth +
295        /// age*año). Para Solar return suele ser 0 (el return cae cerca
296        /// del cumpleaños); para Lunar return permite saltar de un
297        /// retorno mensual al siguiente (~28 días por click).
298        shift_days: i64,
299    },
300    /// `module_id = "midpoints"` — anillo de puntos medios entre pares
301    /// de cuerpos natales. Por simplicidad filtramos a los que
302    /// involucran al Sol o a la Luna (~10 puntos).
303    Midpoints,
304    /// `module_id = "composite"` — carta compuesta (midpoint composite,
305    /// método Davison) entre dos sujetos. Renderea los planetas
306    /// compuestos en un anillo interno propio (radio 0.36, entre solar
307    /// arc 0.40 y aspects). Útil para análisis de relaciones.
308    Composite {
309        partner_chart: Box<Chart>,
310    },
311    /// `module_id = "uranian"` — calcula los "ejes" del dial uraniano
312    /// de 90°: agrupa los cuerpos natales cuya longitud módulo 90 cae
313    /// dentro de una tolerancia (~2°). El resultado se publica en
314    /// `RenderModel.uranian_groups` para que la UI lo liste como
315    /// fórmulas analíticas. La visualización geométrica completa del
316    /// dial de 90° queda pendiente para una fase posterior.
317    Uranian,
318    /// `module_id = "lots"` — Lots arábigos (helenísticos) calculados
319    /// via `eternal_astrology::compute_lot`: Fortune, Spirit, Eros,
320    /// Necessity, Courage, Victory, Nemesis. Renderea cada lot como
321    /// un texto pequeño en el ring de bodies natales.
322    Lots,
323    /// `module_id = "fixed_stars"` — overlay con ~9 estrellas fijas
324    /// notables (Aldebaran, Regulus, Antares, Fomalhaut, Spica,
325    /// Sirius, Algol, Vega, Pollux). Posiciones tropicales J2000
326    /// aproximadas + precesión simple (~50.29″/año). Renderea como
327    /// marcadores chicos justo afuera del sign dial.
328    FixedStars,
329    /// `module_id = "topocentric"` — capa "ascensional": planetas
330    /// re-proyectados a longitud eclíptica topocéntrica (con paralaje
331    /// horizontal aplicada por cuerpo) + casas Polich-Page (sistema
332    /// topocéntrico de domificación). Visible sobre todo en la Luna
333    /// (~1° de shift); imperceptible en planetas exteriores. La capa
334    /// convive con la natal geocéntrica como overlay comparativo.
335    Topocentric,
336    /// `module_id = "pd_direct"` + `"pd_converse"` — Direcciones
337    /// Primarias del Sistema GR (García Rosas). Cada cuerpo natal se
338    /// proyecta dos veces: hacia adelante en el tiempo diurno
339    /// (direct) y hacia atrás (converse). Los dos resultados a la
340    /// edad pedida pintan un dual-ring para rectificación en vivo.
341    ///
342    /// `key` controla la conversión arco↔año: "naibod" (default
343    /// moderno, 0°59'08.33″/año) o "ptolemy" (clásica, 1°/año).
344    PrimaryDirections {
345        target_age_years: f64,
346        key: String,
347    },
348}
349
350/// Opciones que afectan la pasada natal (qué aspectos pintar, qué
351/// multiplicador de orbe usar). Es independiente de los overlays.
352#[derive(Debug, Clone)]
353pub struct NatalOptions {
354    /// Incluir aspectos mayores (conj/opp/trine/square/sextile).
355    pub show_majors: bool,
356    /// Incluir aspectos menores (quincunx/semi-sextile/etc).
357    pub show_minors: bool,
358    /// Multiplicador uniforme sobre los orbes default. `1.0` = orbes
359    /// modern_western; `0.5` = tight; `2.0` = wide.
360    pub orb_multiplier: f64,
361    /// Si `true`, anota cada cuerpo natal con su dignidad esencial
362    /// (domicilio +, exaltación ·, exilio −, caída *). El canvas lo
363    /// renderea como sufijo del glifo.
364    pub show_dignities: bool,
365}
366
367impl Default for NatalOptions {
368    fn default() -> Self {
369        Self {
370            show_majors: true,
371            show_minors: false,
372            orb_multiplier: 1.0,
373            show_dignities: false,
374        }
375    }
376}
377
378/// Composición canónica: carta natal + todos los overlays pedidos.
379/// Equivalente a `compose_with_options` con `NatalOptions::default()`.
380pub fn compose(
381    chart: &Chart,
382    offset_minutes: i64,
383    requests: &[PipelineRequest],
384) -> Result<RenderModel, EngineError> {
385    compose_with_options(chart, offset_minutes, requests, &NatalOptions::default())
386}
387
388/// Variante que permite controlar qué aspectos natales se computan y
389/// con qué multiplicador de orbe.
390pub fn compose_with_options(
391    chart: &Chart,
392    offset_minutes: i64,
393    requests: &[PipelineRequest],
394    natal_options: &NatalOptions,
395) -> Result<RenderModel, EngineError> {
396    #[cfg(feature = "eternal-bridge")]
397    {
398        bridge::compose(chart, offset_minutes, requests, natal_options)
399    }
400    #[cfg(not(feature = "eternal-bridge"))]
401    {
402        let _ = (offset_minutes, requests, natal_options);
403        Ok(compute_mock(chart))
404    }
405}
406
407/// Atajo: natal sin overlays. Equivalente a `compose(chart, 0, &[])`.
408pub fn compute(chart: &Chart) -> Result<RenderModel, EngineError> {
409    compose(chart, 0, &[])
410}
411
412/// Atajo: natal con time-scrubbing pero sin overlays.
413pub fn compute_at_offset(chart: &Chart, offset_minutes: i64) -> Result<RenderModel, EngineError> {
414    compose(chart, offset_minutes, &[])
415}
416
417/// Atajo: natal + overlay de tránsitos al instante actual.
418pub fn compute_with_transits_at_now(
419    chart: &Chart,
420    offset_minutes: i64,
421) -> Result<RenderModel, EngineError> {
422    compose(chart, offset_minutes, &[PipelineRequest::Transit])
423}
424
425/// Helper retrocompatible: construye un `PlanetaryReturn` con
426/// `shift_days = 0`. Útil para llamadores que no necesitan ajuste
427/// fino (todos los Solar return y muchos casos básicos).
428pub fn planetary_return_request(body: String, target_age_years: f64) -> PipelineRequest {
429    PipelineRequest::PlanetaryReturn {
430        body,
431        target_age_years,
432        shift_days: 0,
433    }
434}
435
436/// Stub determinista — útil para tests + para la UI sin eternal.
437pub fn compute_mock(chart: &Chart) -> RenderModel {
438    use std::time::Instant;
439    let t0 = Instant::now();
440
441    let sign_dial = Layer {
442        module_id: "natal".into(),
443        kind: LayerKind::SignDial,
444        ring: 1.0,
445        z: 0,
446        geometry: Geometry::Ring {
447            cusps_deg: (0..12).map(|i| (i as f32) * 30.0).collect(),
448        },
449        glyphs: (0..12)
450            .map(|i| Glyph {
451                deg: (i as f32) * 30.0 + 15.0,
452                symbol: ZODIAC_GLYPHS[i].into(),
453                annotation: None,
454                retrograde: false,
455                house: None,
456            dignity_marker: None,
457            })
458            .collect(),
459    };
460
461    RenderModel {
462        chart_id: chart.id,
463        chart_kind: chart.kind,
464        title: chart.label.clone(),
465        subtitle: chart.birth_data.birthplace_label.clone(),
466        compute_ms: t0.elapsed().as_millis() as u64,
467        ascendant_deg: 0.0,
468        midheaven_deg: 270.0,
469        descendant_deg: 180.0,
470        imum_coeli_deg: 90.0,
471        layers: vec![sign_dial],
472        overlays: Vec::new(),
473        aspect_summary: Vec::new(),
474        uranian_groups: Vec::new(),
475    }
476}
477
478const ZODIAC_GLYPHS: [&str; 12] = [
479    "aries",
480    "taurus",
481    "gemini",
482    "cancer",
483    "leo",
484    "virgo",
485    "libra",
486    "scorpio",
487    "sagittarius",
488    "capricorn",
489    "aquarius",
490    "pisces",
491];
492
493// =====================================================================
494// Tests
495// =====================================================================
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use tahuantinsuyu_model::{
501        Chart, ChartKind, ContactId, StoredBirthData, StoredChartConfig,
502    };
503
504    fn sample_chart() -> Chart {
505        Chart {
506            id: ChartId::new(),
507            contact_id: ContactId::new(),
508            kind: ChartKind::Natal,
509            label: "test".into(),
510            birth_data: StoredBirthData {
511                year: 1987,
512                month: 3,
513                day: 14,
514                hour: 5,
515                minute: 22,
516                second: 0.0,
517                tz_offset_minutes: -240,
518                latitude_deg: 10.4806,
519                longitude_deg: -66.9036,
520                altitude_m: 900.0,
521                time_certainty: Default::default(),
522                subject_name: None,
523                birthplace_label: None,
524            },
525            config: StoredChartConfig::default(),
526            related_chart_id: None,
527            created_at_ms: 0,
528        }
529    }
530
531    #[test]
532    fn mock_emits_sign_dial() {
533        let model = compute_mock(&sample_chart());
534        assert_eq!(model.layers.len(), 1);
535        assert!(matches!(model.layers[0].kind, LayerKind::SignDial));
536        assert_eq!(model.layers[0].glyphs.len(), 12);
537    }
538
539    #[cfg(feature = "eternal-bridge")]
540    #[test]
541    fn real_compute_natal_demo() {
542        let model = compute(&sample_chart()).expect("compute con eternal");
543        assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::SignDial)));
544        assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::Houses)));
545        assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::Bodies)));
546        // El Asc debe ser un grado válido.
547        assert!(model.ascendant_deg.is_finite());
548        assert!((0.0..360.0).contains(&model.ascendant_deg));
549    }
550
551    /// El cache de NatalChart debe hacer que la segunda llamada con
552    /// inputs idénticos sea sustancialmente más rápida que la primera.
553    /// Verificamos un piso del 4× — en práctica el ratio suele ser
554    /// >10× porque la primera carga VSOP2013 también.
555    #[cfg(feature = "eternal-bridge")]
556    #[test]
557    fn natal_cache_hits_are_faster() {
558        let chart = sample_chart();
559        // Warmup: abre la sesión de efemérides y puebla el cache.
560        let _ = compute(&chart).expect("warmup");
561
562        // Reset implícito: insertar una clave distinta no botaría la
563        // nuestra (cap=8) pero la marcaría como más vieja. Como solo
564        // tenemos 1 entrada, sigue al frente.
565        let t1 = std::time::Instant::now();
566        let _ = compute(&chart).expect("primera medida");
567        let cold_or_hot_1 = t1.elapsed();
568
569        let t2 = std::time::Instant::now();
570        let _ = compute(&chart).expect("segunda medida");
571        let hot = t2.elapsed();
572
573        // Después del warmup, las dos llamadas son hot. Para validar el
574        // efecto del cache, modificamos el offset_minutes para forzar
575        // un MISS y comparar contra un HIT.
576        use crate::PipelineRequest;
577        let t3 = std::time::Instant::now();
578        let _ = compose(&chart, 17, &[] as &[PipelineRequest])
579            .expect("miss con offset distinto");
580        let miss = t3.elapsed();
581
582        let t4 = std::time::Instant::now();
583        let _ = compose(&chart, 17, &[] as &[PipelineRequest])
584            .expect("hit con mismo offset");
585        let hit = t4.elapsed();
586
587        // Sanity: el hit debe ser estrictamente más rápido que el miss.
588        assert!(
589            hit < miss,
590            "cache hit ({:?}) debería ser más rápido que miss ({:?}); \
591             warmup={:?}, repeat={:?}",
592            hit, miss, cold_or_hot_1, hot
593        );
594    }
595}