tawasuyu

tawasuyu/brahman

Rust 496TOML 195Markdown 55JSON 197001 Sutichaykuna

feat(tahuantinsuyu): fase 21 — background compute + UranianModule

Cierre del brief original — última pieza visual (Uraniano) + perf.

## #1 — Compute en background thread

render_current() pasa de bloqueante a async. La pipeline corre en
cx.background_executor().spawn (no UI thread), y al terminar el
update vuelve al UI vía cx.spawn. Sin esto, un drag del slider con
muchos overlays bloquea el frame por hasta 200ms.

Cancelación: Shell gana `render_seq: u64`. Cada render_current()
incrementa el counter y captura su número; el closure async compara
antes de aplicar. Si llegó un compute más nuevo en el medio (drag
rápido), el viejo se descarta — evita el race donde un cómputo
lento sobrescribe uno reciente y rápido.

Inputs al background: Chart clonado + offset + Vec<PipelineRequest>
+ NatalOptions. La sesión VSOP2013 sigue siendo `static OnceLock`
read-only, accesible desde cualquier thread.

## #11 — UranianModule (versión textual)

Cierra la última pieza del brief original. Toggle "Uraniano (90°)"
en el panel; engine detecta cuerpos natales cuya longitud módulo 90
cae dentro de ε=2° y los agrupa como "ejes". Footer renderea cada
grupo como pill con los unicodes (☉ ♃ · 14.3°) bajo el header
"Ejes uranianos (90°)".

El algoritmo:
1. mod90 = longitude.rem_euclid(90.0) para cada placement
2. Sort por mod90 ascendente
3. Walk lineal agrupando entradas con diff(mod90) ≤ ε
4. Wrap-around check: el primer y último grupo se mergean si
   abarcan el cierre del dial (88→2 = solo 4° de diff modular)
5. Solo emite grupos con 2+ miembros (singletons no son fórmulas)

- engine: PipelineRequest::Uranian + UranianGroup struct +
  build_uranian_groups helper. RenderModel gana uranian_groups field.
  push_overlay_meta tipo "Uraniano · N ejes" o "sin ejes".
- modules: uranian::UranianModule (toggle "Activar"). Registry pasa
  a 9 módulos para ChartKind::Natal. Test actualizado.
- shell: build_requests detecta uranian.enabled, pushea
  PipelineRequest::Uranian (sin parámetros).
- canvas: footer agrega sección "Ejes uranianos (90°)" con pills
  arriba de la lista de aspectos — border angle_highlight para
  invitar a la lectura.

La visualización geométrica completa del dial de 90° con árbol de
simetría al hover queda para una fase posterior — esta versión
textual cubre el caso analítico (ver qué cuerpos están "en
relación uraniana") sin requerir un canvas secundario.

cargo check verde, 8 tests engine + 1 test modules (9 módulos
aplicables a ChartKind::Natal) verdes.

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

d890bd4b3a7aa8af918e854c1ae6754ca08673cd · ruwaq sergio · (5 killa ñawpaq) · tayta d3649bfd1 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 24

crates/apps/tahuantinsuyu/src/shell.rs

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

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

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

crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs

5 qillqakuna · +269 −29

diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs
index 4238a46..b783f5c 100644
--- a/crates/apps/tahuantinsuyu/src/shell.rs
+++ b/crates/apps/tahuantinsuyu/src/shell.rs
@@ -59,6 +59,11 @@ pub struct Shell {
     /// `module_id`. Las claves dentro del JSON dependen del módulo (la
     /// convención es `"enabled": bool` para el toggle principal).
     module_configs: HashMap<String, serde_json::Value>,
+    /// Sequence counter para descartar resultados de cómputos
+    /// background que llegan después de uno más reciente. Cada
+    /// `render_current` lo incrementa y la closure async compara antes
+    /// de aplicar el render al canvas.
+    render_seq: u64,
 }
 
 impl Shell {
@@ -94,6 +99,7 @@ impl Shell {
             current_chart: None,
             current_offset_minutes: 0,
             module_configs: HashMap::new(),
+            render_seq: 0,
         };
         shell.refresh_chart_options(cx);
         shell
@@ -256,6 +262,9 @@ impl Shell {
         if module_enabled(&self.module_configs, "midpoints") {
             requests.push(PipelineRequest::Midpoints);
         }
+        if module_enabled(&self.module_configs, "uranian") {
+            requests.push(PipelineRequest::Uranian);
+        }
         if module_enabled(&self.module_configs, "composite") {
             if let Some(partner) = self.resolve_composite_partner() {
                 requests.push(PipelineRequest::Composite {
@@ -464,34 +473,63 @@ impl Shell {
         let Some(chart) = self.current_chart.as_ref() else {
             return;
         };
+        // Snapshot de inputs para mover al background. La sesión
+        // VSOP2013 vive en un static `OnceLock` adentro del bridge, así
+        // que es compartible read-only entre threads sin que ningún
+        // dato cruce más allá del Chart clonado + requests/options.
+        let chart = chart.clone();
+        let offset = self.current_offset_minutes;
         let requests = self.build_requests();
         let natal_options = self.build_natal_options();
-        let render = match compose_with_options(
-            chart,
-            self.current_offset_minutes,
-            &requests,
-            &natal_options,
-        ) {
-            Ok(r) => r,
-            Err(e) => {
-                eprintln!(
-                    "[shell] compose {} (+{}min, {} reqs): {}",
-                    chart.id,
-                    self.current_offset_minutes,
-                    requests.len(),
-                    e
-                );
-                return;
-            }
-        };
-        self.canvas.update(cx, |c, cx| {
-            c.set_mode(
-                CanvasMode::Wheel {
-                    render: Box::new(render),
-                },
-                cx,
-            );
-        });
+        self.render_seq = self.render_seq.wrapping_add(1);
+        let my_seq = self.render_seq;
+
+        cx.spawn(async move |this, cx| {
+            // El compute corre en el background_executor — no bloquea
+            // el UI thread. Para una rueda completa con varios overlays
+            // puede tomar 100-200ms; sin esto, los drags del slider se
+            // sentirían atorados.
+            let chart_for_bg = chart.clone();
+            let requests_for_bg = requests.clone();
+            let opts_for_bg = natal_options.clone();
+            let result = cx
+                .background_executor()
+                .spawn(async move {
+                    compose_with_options(&chart_for_bg, offset, &requests_for_bg, &opts_for_bg)
+                })
+                .await;
+
+            let _ = this.update(cx, |this, cx| {
+                // Descartar si llegó un render más nuevo en el medio.
+                // Sin este check, durante un drag rápido un compute
+                // viejo podría sobrescribir el más reciente.
+                if this.render_seq != my_seq {
+                    return;
+                }
+                match result {
+                    Ok(render) => {
+                        this.canvas.update(cx, |c, cx| {
+                            c.set_mode(
+                                CanvasMode::Wheel {
+                                    render: Box::new(render),
+                                },
+                                cx,
+                            );
+                        });
+                    }
+                    Err(e) => {
+                        eprintln!(
+                            "[shell] compose {} (+{}min, {} reqs): {}",
+                            chart.id,
+                            offset,
+                            requests.len(),
+                            e
+                        );
+                    }
+                }
+            });
+        })
+        .detach();
     }
 
     fn on_canvas_event(&mut self, ev: &CanvasEvent, cx: &mut Context<Self>) {
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
index 7b22247..ca9861a 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
@@ -1005,6 +1005,51 @@ fn render_wheel(
         footer = footer.child(b);
     }
 
+    // Ejes uranianos detectados (cuerpos en la misma posición mod 90).
+    // Aparece sólo cuando el módulo Uranian está activo y hay
+    // grupos. Cada grupo se muestra como pill con los unicode de los
+    // cuerpos + el grado dial-90.
+    if !render.uranian_groups.is_empty() {
+        let mut row = div().flex().flex_row().flex_wrap().gap(px(6.0));
+        for group in &render.uranian_groups {
+            let bodies_text: String = group
+                .bodies
+                .iter()
+                .map(|b| planet_unicode(b))
+                .collect::<Vec<_>>()
+                .join(" ");
+            row = row.child(
+                div()
+                    .px(px(8.0))
+                    .py(px(2.0))
+                    .rounded(px(10.0))
+                    .bg(theme.bg_panel_alt.clone())
+                    .border_1()
+                    .border_color(with_alpha(palette.angle_highlight, 0.6))
+                    .text_size(px(11.0))
+                    .text_color(theme.fg_text)
+                    .child(SharedString::from(format!(
+                        "{}  ·  {:.1}°",
+                        bodies_text, group.mod90_deg
+                    ))),
+            );
+        }
+        footer = footer.child(
+            div()
+                .flex()
+                .flex_col()
+                .items_center()
+                .gap(px(3.0))
+                .child(
+                    div()
+                        .text_size(px(10.0))
+                        .text_color(theme.fg_muted)
+                        .child("Ejes uranianos (90°)"),
+                )
+                .child(row),
+        );
+    }
+
     // Lista textual de aspectos (top 12 por orb). Compacta, en grid
     // de 3 columnas, fonts pequeños. Solo aparece cuando hay aspectos
     // computados.
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
index cab0f07..2b8c44a 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
@@ -20,7 +20,7 @@ use tahuantinsuyu_model::{Chart, HouseSystem, StoredChartConfig, Zodiac};
 use crate::dignity::essential_dignity;
 use crate::{
     AspectSummary, EngineError, Geometry, Glyph, Layer, LayerKind, LineSeg, OverlayMeta,
-    RenderModel,
+    RenderModel, UranianGroup,
 };
 
 // =====================================================================
@@ -343,6 +343,19 @@ pub fn compose(
                     format!("Composite · {}", partner_label),
                 );
             }
+            crate::PipelineRequest::Uranian => {
+                build_uranian_groups(&natal, &mut render);
+                let n = render.uranian_groups.len();
+                push_overlay_meta(
+                    &mut render,
+                    "uranian",
+                    if n == 0 {
+                        "Uraniano · sin ejes".into()
+                    } else {
+                        format!("Uraniano · {} ejes", n)
+                    },
+                );
+            }
         }
     }
 
@@ -487,6 +500,75 @@ fn build_progression_overlay(
     Ok(())
 }
 
+/// Helper: detecta "ejes" del dial uraniano de 90° — cuerpos natales
+/// cuya longitud módulo 90 cae dentro de una tolerancia ε (2° por
+/// default). Llena `render.uranian_groups` con los grupos detectados.
+fn build_uranian_groups(natal: &NatalChart, render: &mut RenderModel) {
+    const EPSILON: f64 = 2.0;
+    let mut entries: Vec<(String, f64)> = natal
+        .placements
+        .iter()
+        .map(|p| {
+            let lon = p.longitude.longitude_deg();
+            let mod90 = lon.rem_euclid(90.0);
+            (body_symbol(p.body).to_string(), mod90)
+        })
+        .collect();
+    entries.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
+
+    let mut groups: Vec<UranianGroup> = Vec::new();
+    let mut current: Vec<(String, f64)> = Vec::new();
+    let mut anchor_mod90 = 0.0_f64;
+    for entry in entries {
+        if current.is_empty() {
+            anchor_mod90 = entry.1;
+            current.push(entry);
+            continue;
+        }
+        // Distancia circular módulo 90 entre el entry y el anchor.
+        let mut diff = (entry.1 - anchor_mod90).abs();
+        if diff > 45.0 {
+            diff = 90.0 - diff;
+        }
+        if diff <= EPSILON {
+            current.push(entry);
+        } else {
+            if current.len() >= 2 {
+                let center = current.iter().map(|(_, m)| *m).sum::<f64>() / current.len() as f64;
+                groups.push(UranianGroup {
+                    bodies: current.iter().map(|(s, _)| s.clone()).collect(),
+                    mod90_deg: center,
+                });
+            }
+            anchor_mod90 = entry.1;
+            current = vec![entry];
+        }
+    }
+    if current.len() >= 2 {
+        let center = current.iter().map(|(_, m)| *m).sum::<f64>() / current.len() as f64;
+        groups.push(UranianGroup {
+            bodies: current.iter().map(|(s, _)| s.clone()).collect(),
+            mod90_deg: center,
+        });
+    }
+    // Wrap-around check: el primer y último grupo podrían ser el mismo
+    // (si span >88° abarcando el wrap en 90/0). Si los anchors están a
+    // ≤EPSILON modulo 90, mergeamos.
+    if groups.len() >= 2 {
+        let first_mod = groups[0].mod90_deg;
+        let last_mod = groups[groups.len() - 1].mod90_deg;
+        let mut diff = (first_mod - last_mod).abs();
+        if diff > 45.0 {
+            diff = 90.0 - diff;
+        }
+        if diff <= EPSILON {
+            let last = groups.pop().unwrap();
+            groups[0].bodies.extend(last.bodies);
+        }
+    }
+    render.uranian_groups = groups;
+}
+
 /// Helper: agrega al `RenderModel` la carta compuesta (midpoint
 /// composite, Davison 1958) entre la natal del sujeto y la carta del
 /// partner. Cada planeta compuesto es el angular midpoint entre los
@@ -972,6 +1054,7 @@ fn build_render_model(
         layers: vec![sign_dial, houses, bodies, aspects_layer],
         overlays: Vec::new(),
         aspect_summary: Vec::new(),
+        uranian_groups: Vec::new(),
     }
 }
 
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
index fdd5e88..7a51477 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
@@ -75,6 +75,10 @@ pub struct RenderModel {
     /// cerrados primero). La UI lo usa para la lista textual.
     #[serde(default)]
     pub aspect_summary: Vec<AspectSummary>,
+    /// Grupos uranianos detectados (cuerpos en la misma posición mod 90).
+    /// Vacío sino se activó el módulo Uranian.
+    #[serde(default)]
+    pub uranian_groups: Vec<UranianGroup>,
 }
 
 /// Etiqueta legible de un overlay para el footer del canvas. La engine
@@ -87,6 +91,23 @@ pub struct OverlayMeta {
     pub label: String,
 }
 
+/// Grupo de cuerpos natales que caen en la misma posición del
+/// dial uraniano de 90° (su longitud zodiacal módulo 90 es igual o
+/// muy cercana). En la astrología uraniana esto es una "fórmula" o
+/// "axis" — los cuerpos están en correspondencia simbólica directa
+/// porque comparten un cuadrante simétrico.
+///
+/// Solo se emiten grupos con 2+ miembros (los singletons no son
+/// fórmulas). La engine los ordena por proximidad al ε de tolerancia.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct UranianGroup {
+    /// Identificadores agnósticos de los cuerpos en el grupo
+    /// (ej. `["sun", "jupiter", "saturn"]`).
+    pub bodies: Vec<String>,
+    /// Posición en el dial de 90° (la longitud módulo 90).
+    pub mod90_deg: f64,
+}
+
 /// Resumen textual de un aspecto para listas legibles. La engine lo
 /// emite en paralelo con las `LineSeg` de la capa de aspectos, así
 /// el canvas no tiene que re-derivar nombres de cuerpos desde grados.
@@ -274,6 +295,13 @@ pub enum PipelineRequest {
     Composite {
         partner_chart: Box<Chart>,
     },
+    /// `module_id = "uranian"` — calcula los "ejes" del dial uraniano
+    /// de 90°: agrupa los cuerpos natales cuya longitud módulo 90 cae
+    /// dentro de una tolerancia (~2°). El resultado se publica en
+    /// `RenderModel.uranian_groups` para que la UI lo liste como
+    /// fórmulas analíticas. La visualización geométrica completa del
+    /// dial de 90° queda pendiente para una fase posterior.
+    Uranian,
 }
 
 /// Opciones que afectan la pasada natal (qué aspectos pintar, qué
@@ -400,6 +428,7 @@ pub fn compute_mock(chart: &Chart) -> RenderModel {
         layers: vec![sign_dial],
         overlays: Vec::new(),
         aspect_summary: Vec::new(),
+        uranian_groups: Vec::new(),
     }
 }
 
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
index c689170..fd35da8 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
@@ -139,6 +139,7 @@ impl Registry {
         r.register(Box::new(planetary_return::PlanetaryReturnModule));
         r.register(Box::new(midpoints::MidpointsModule));
         r.register(Box::new(composite::CompositeModule));
+        r.register(Box::new(uranian::UranianModule));
         r
     }
 
@@ -663,8 +664,52 @@ mod tests {
         assert!(r.find("planetary_return").is_some());
         assert!(r.find("midpoints").is_some());
         assert!(r.find("composite").is_some());
-        // Natal kind tiene 8 módulos aplicables.
-        assert_eq!(r.for_kind(ChartKind::Natal).len(), 8);
+        assert!(r.find("uranian").is_some());
+        // Natal kind tiene 9 módulos aplicables.
+        assert_eq!(r.for_kind(ChartKind::Natal).len(), 9);
         assert!(r.for_kind(ChartKind::Synastry).is_empty());
     }
 }
+
+// =====================================================================
+// UranianModule — ejes del dial uraniano de 90° (versión textual)
+// =====================================================================
+
+pub mod uranian {
+    use super::*;
+
+    /// Detecta "ejes" del dial uraniano: grupos de cuerpos natales cuya
+    /// longitud módulo 90 cae dentro de una tolerancia. Los grupos
+    /// resultantes se listan en el footer del canvas. La visualización
+    /// geométrica del dial completo de 90° queda para una fase futura.
+    pub struct UranianModule;
+
+    impl Module for UranianModule {
+        fn id(&self) -> &'static str {
+            "uranian"
+        }
+        fn label(&self) -> &'static str {
+            "Uraniano (90°)"
+        }
+        fn description(&self) -> &'static str {
+            "Ejes del dial uraniano — cuerpos en la misma posición mod 90."
+        }
+        fn applies_to(&self, kind: ChartKind) -> bool {
+            matches!(kind, ChartKind::Natal)
+        }
+        fn enabled_by_default(&self) -> bool {
+            false
+        }
+        fn controls(&self) -> Vec<Control> {
+            vec![Control::Toggle {
+                key: "enabled".into(),
+                label: "Activar".into(),
+                default: false,
+                hotkey: None,
+            }]
+        }
+        fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
+            Vec::new()
+        }
+    }
+}