chore: rename tahuantinsuyu → cosmobiologia

Rename clean del proyecto astrológico antes de empezar el módulo
web (fase 2 = server axum, fase 3 = cliente WASM). Hacerlo ahora
ahorra refactor de URLs, package.json, paths de assets HTML y
deploy configs que aparecerían con el nombre en cuanto exista el
server.

Mecánica:
- `git mv` de los 10 crates de módulo + 2 apps:
  * `crates/modules/tahuantinsuyu/` → `cosmobiologia/`
  * `crates/modules/tahuantinsuyu/tahuantinsuyu-*` →
    `cosmobiologia/cosmobiologia-*`
  * `crates/apps/tahuantinsuyu` y `tahuantinsuyu-cli` análogos.
- Sed sobre todos los `.rs` y `.toml`: `tahuantinsuyu` →
  `cosmobiologia` (cubre crate names, deps paths, use
  statements, ProjectDirs literals, binary names).
- Workspace `Cargo.toml`: members con paths nuevos.
- Memoria del proyecto (`~/.claude/.../memory/project_*.md`)
  actualizada.

Cero leftovers: `grep -rn tahuantinsuyu --include="*.rs"
--include="*.toml" crates/` devuelve vacío.

DB & XDG: clean slate. La nueva app arranca con DB vacía en
`$XDG_DATA_HOME/cosmobiologia/charts.db`. Si tenías cartas
guardadas, viven todavía en `~/.local/share/tahuantinsuyu/` —
las podés migrar manualmente con un `cp`.

IDs UI inalterados: el prefijo `tts-` de gpui ElementIds queda
igual (cosmético, no afecta funcionalidad). Cambiarlo a `cb-`
ahora sería 3-4 líneas más de sed pero ningún beneficio
operativo.

Tests: 20 verdes (10 shell + 10 render math). Compila full:
`cargo check -p cosmobiologia` OK.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-19 00:45:48 +00:00
parent 9084cf4b79
commit 06a1ca11ce
34 changed files with 325 additions and 315 deletions
@@ -0,0 +1,32 @@
[package]
name = "cosmobiologia-engine"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "Tahuantinsuyu — bridge entre el modelo agnóstico y eternal-astrology. Produce RenderModel agnóstico para el canvas."
[dependencies]
cosmobiologia-model = { path = "../cosmobiologia-model" }
cosmobiologia-render = { path = "../cosmobiologia-render" }
serde = { workspace = true }
thiserror = { workspace = true }
# eternal-astrology vive en otro workspace (~/eternal). Lo enlazamos por
# path para que el bridge use la misma lógica validada que el harness de
# Sergio. Si el path no existe (CI sin eternal checked out), el feature
# `eternal-bridge` se apaga.
[dependencies.eternal-astrology]
path = "../../../../../eternal/eternal-astrology"
optional = true
[dependencies.eternal-sky]
path = "../../../../../eternal/eternal-sky"
optional = true
[features]
# El bridge real contra eternal-astrology está prendido por default
# porque la app sin eternal no muestra cartas reales. Si necesitás
# compilar sin eternal checked out (CI, builds aisladas), `--no-default-features`
# lo apaga y `compute()` cae a `compute_mock()`.
default = ["eternal-bridge"]
eternal-bridge = ["dep:eternal-astrology", "dep:eternal-sky"]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,141 @@
//! Dignidades esenciales clásicas — tabla data-only.
//!
//! Cada planeta tradicional tiene cuatro estatus posibles según el
//! signo en el que cae:
//!
//! - **Domicilio** (rulership) — el signo del que es regente.
//! - **Exaltación** — un signo "huésped" que le da fuerza extra.
//! - **Exilio** (detriment) — opuesto al domicilio, debilita.
//! - **Caída** (fall) — opuesto a la exaltación, debilita.
//!
//! Esta tabla usa las regencias **clásicas** (Aries=Marte, Escorpio=
//! Marte, Acuario=Saturno, Piscis=Júpiter) — los planetas modernos
//! (Urano/Neptuno/Plutón) no tienen regencia clásica por convención.
//! En una fase futura podemos exponer un toggle "regencias modernas"
//! que mapee Escorpio→Plutón, Acuario→Urano, Piscis→Neptuno.
use eternal_sky::Body;
/// Status de dignidad esencial de un cuerpo en un signo dado.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Dignity {
/// Domicilio. Marker `"+"`.
Rulership,
/// Exaltación. Marker `"·"`.
Exaltation,
/// Exilio. Marker `""`.
Detriment,
/// Caída. Marker `"*"`.
Fall,
}
impl Dignity {
pub fn marker(self) -> &'static str {
match self {
Dignity::Rulership => "+",
Dignity::Exaltation => "·",
Dignity::Detriment => "",
Dignity::Fall => "*",
}
}
}
/// Devuelve el status de dignidad de `body` en `sign_index` (0..12,
/// Aries=0) o `None` si no aplica (sin dignidad / cuerpo moderno sin
/// regencia clásica).
pub fn essential_dignity(body: Body, sign_index: u8) -> Option<Dignity> {
let sign = sign_index % 12;
let opposite = (sign + 6) % 12;
// Rulership clásico — el "regente" del signo.
if rules_classical(body, sign) {
return Some(Dignity::Rulership);
}
// Detriment = el cuerpo gobierna el signo opuesto.
if rules_classical(body, opposite) {
return Some(Dignity::Detriment);
}
// Exaltación tabular.
if exalts_at(body) == Some(sign) {
return Some(Dignity::Exaltation);
}
// Caída = opuesto a la exaltación.
if exalts_at(body) == Some(opposite) {
return Some(Dignity::Fall);
}
None
}
/// Devuelve true si `body` gobierna `sign` (0=Aries..11=Pisces) en el
/// esquema clásico de 7 planetas.
fn rules_classical(body: Body, sign: u8) -> bool {
match (body, sign) {
// Sol: Leo (4)
(Body::Sun, 4) => true,
// Luna: Cancer (3)
(Body::Moon, 3) => true,
// Mercurio: Gemini (2), Virgo (5)
(Body::Mercury, 2) | (Body::Mercury, 5) => true,
// Venus: Taurus (1), Libra (6)
(Body::Venus, 1) | (Body::Venus, 6) => true,
// Marte: Aries (0), Scorpio (7)
(Body::Mars, 0) | (Body::Mars, 7) => true,
// Júpiter: Sagittarius (8), Pisces (11)
(Body::Jupiter, 8) | (Body::Jupiter, 11) => true,
// Saturno: Capricorn (9), Aquarius (10)
(Body::Saturn, 9) | (Body::Saturn, 10) => true,
_ => false,
}
}
/// Devuelve el signo (0..12) donde el cuerpo exalta, o `None` si no
/// tiene exaltación clásica documentada.
fn exalts_at(body: Body) -> Option<u8> {
Some(match body {
Body::Sun => 0, // Aries
Body::Moon => 1, // Taurus
Body::Mercury => 5, // Virgo (algunas tradiciones la ponen acá)
Body::Venus => 11, // Pisces
Body::Mars => 9, // Capricorn
Body::Jupiter => 3, // Cancer
Body::Saturn => 6, // Libra
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rulership_examples() {
assert_eq!(essential_dignity(Body::Sun, 4), Some(Dignity::Rulership)); // Sol en Leo
assert_eq!(essential_dignity(Body::Moon, 3), Some(Dignity::Rulership)); // Luna en Cancer
assert_eq!(essential_dignity(Body::Mars, 7), Some(Dignity::Rulership)); // Marte en Scorpio
}
#[test]
fn detriment_examples() {
assert_eq!(essential_dignity(Body::Sun, 10), Some(Dignity::Detriment)); // Sol en Acuario
assert_eq!(essential_dignity(Body::Moon, 9), Some(Dignity::Detriment)); // Luna en Capricornio
}
#[test]
fn exaltation_examples() {
assert_eq!(essential_dignity(Body::Sun, 0), Some(Dignity::Exaltation)); // Sol en Aries
assert_eq!(essential_dignity(Body::Saturn, 6), Some(Dignity::Exaltation)); // Saturno en Libra
}
#[test]
fn fall_examples() {
assert_eq!(essential_dignity(Body::Sun, 6), Some(Dignity::Fall)); // Sol en Libra
assert_eq!(essential_dignity(Body::Saturn, 0), Some(Dignity::Fall)); // Saturno en Aries
}
#[test]
fn modern_planets_no_classical_dignity() {
assert_eq!(essential_dignity(Body::Uranus, 10), None);
assert_eq!(essential_dignity(Body::Neptune, 11), None);
assert_eq!(essential_dignity(Body::Pluto, 7), None);
}
}
@@ -0,0 +1,459 @@
//! `cosmobiologia-engine` — bridge entre el modelo agnóstico y
//! `eternal-astrology`.
//!
//! Recibe un `Chart` del modelo + un `ChartKind` y devuelve un
//! [`RenderModel`] que describe la geometría a pintar **sin** acoplar
//! el canvas a tipos de la librería astronómica. El canvas habla
//! grados decimales, radios normalizados y kinds simbólicos.
//!
//! ## Por qué un RenderModel intermedio
//!
//! 1. El canvas no debería caer si cambia el shape de `NatalChart`
//! upstream.
//! 2. Tests del canvas: podemos generar `RenderModel`s sintéticos sin
//! arrancar eternal.
//! 3. Cada `ChartKind` produce el mismo shape genérico → el render
//! coordina N módulos sin saber qué calcularon.
//!
//! ## Feature `eternal-bridge`
//!
//! - **on** (default): [`compute`] abre una `EphemerisSession` VSOP2013
//! compartida y corre la pipeline real.
//! - **off**: [`compute`] cae a [`compute_mock`] — útil para tests +
//! builds sin eternal checked out.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
use thiserror::Error;
pub use cosmobiologia_model::{Chart, ChartId, ChartKind};
// Los tipos del RenderModel viven en `cosmobiologia-render` (crate
// agnóstico de surface — compila a WASM, lo consumen tanto el canvas
// gpui como el cliente web). El engine los reexporta para mantener
// compatibilidad con todos los call sites históricos
// (`cosmobiologia_engine::Layer`, etc.) sin tener que cambiar
// imports en el shell, canvas, modules, tree, panel...
pub use cosmobiologia_render::{
AspectSummary, Geometry, Glyph, Layer, LayerKind, LineSeg, OverlayMeta, PointMark,
RenderModel, UranianGroup, OUTER_RING_MODULES,
};
// `Chart` reexportado arriba es lo que `PipelineRequest::Synastry`
// transporta — el caller (shell) lee del Store y pasa el Chart entero
// para que el bridge construya su NatalChart en eternal.
#[cfg(feature = "eternal-bridge")]
mod bridge;
#[cfg(feature = "eternal-bridge")]
mod dignity;
#[cfg(feature = "eternal-bridge")]
mod natal_cache;
#[cfg(feature = "eternal-bridge")]
pub mod svg_export;
// =====================================================================
// Errores
// =====================================================================
#[derive(Debug, Error)]
pub enum EngineError {
#[error("bridge a eternal-astrology no disponible (recompilá con feature `eternal-bridge`)")]
BridgeDisabled,
#[error("model: {0}")]
Model(#[from] cosmobiologia_model::ModelError),
#[error("eternal: {0}")]
Eternal(String),
#[error("kind {0:?} todavía no implementado")]
UnsupportedKind(ChartKind),
}
// =====================================================================
// API pública
// =====================================================================
/// Pedidos que el host (Shell) eleva a la engine para componer un
/// `RenderModel`. La capa natal **siempre** se computa; estos requests
/// son **overlays adicionales**.
///
/// Cada variante mapea 1-a-1 con un Module declarado en
/// `cosmobiologia-modules` por id string. Esto deja la engine como
/// dueña única del cómputo (no depende del trait Module — los módulos
/// son sólo metadata + UI controls).
#[derive(Debug, Clone)]
pub enum PipelineRequest {
/// `module_id = "transit"` — anillo externo con planetas al
/// instante actual (reloj de pared) + cross aspects natal × transit.
Transit,
/// `module_id = "progression"` — anillo interno con los planetas
/// progresados (método secundario "día por año") a la edad pedida
/// + cross aspects natal × progresada.
SecondaryProgression {
/// Edad simbólica en años a la que avanzar la carta. Para "la
/// edad de hoy", el shell la calcula a partir de `birth_data` +
/// `SystemTime::now`.
target_age_years: f64,
},
/// `module_id = "solar_arc"` — Solar Arc dirigido (default = "true
/// progressed Sun"): cada cuerpo y cada cusp natal se desplazan por
/// el mismo arco ≈ 1° por año de vida. Anillo interno bien adentro
/// + cross aspects natal × dirigida.
SolarArc {
target_age_years: f64,
},
/// `module_id = "synastry"` — bi-wheel: la natal en el centro, la
/// carta del partner en el anillo externo (compartido con Transit
/// — mutuamente excluyentes), cross aspects natal × partner.
/// El partner viene como `Chart` completo del shell.
Synastry {
partner_chart: Box<Chart>,
},
/// `module_id = "planetary_return"` — carta natal fresca al
/// instante del próximo retorno del cuerpo elegido a su posición
/// natal, para la edad pedida. Sun = retorno solar anual, Moon =
/// mensual, Júpiter/Saturno = generacionales. Anillo externo
/// compartido con Transit/Synastry — mutuamente excluyentes a
/// nivel de Shell.
PlanetaryReturn {
/// Identificador agnóstico del cuerpo ("sun", "moon",
/// "jupiter", …). El bridge lo mapea a `eternal_sky::Body`.
body: String,
target_age_years: f64,
/// Días extra que se suman al anchor de búsqueda (birth +
/// age*año). Para Solar return suele ser 0 (el return cae cerca
/// del cumpleaños); para Lunar return permite saltar de un
/// retorno mensual al siguiente (~28 días por click).
shift_days: i64,
},
/// `module_id = "midpoints"` — anillo de puntos medios entre pares
/// de cuerpos natales. Por simplicidad filtramos a los que
/// involucran al Sol o a la Luna (~10 puntos).
Midpoints,
/// `module_id = "composite"` — carta compuesta (midpoint composite,
/// método Davison) entre dos sujetos. Renderea los planetas
/// compuestos en un anillo interno propio (radio 0.36, entre solar
/// arc 0.40 y aspects). Útil para análisis de relaciones.
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,
/// `module_id = "lots"` — Lots arábigos (helenísticos) calculados
/// via `eternal_astrology::compute_lot`: Fortune, Spirit, Eros,
/// Necessity, Courage, Victory, Nemesis. Renderea cada lot como
/// un texto pequeño en el ring de bodies natales.
Lots,
/// `module_id = "fixed_stars"` — overlay con ~9 estrellas fijas
/// notables (Aldebaran, Regulus, Antares, Fomalhaut, Spica,
/// Sirius, Algol, Vega, Pollux). Posiciones tropicales J2000
/// aproximadas + precesión simple (~50.29″/año). Renderea como
/// marcadores chicos justo afuera del sign dial.
FixedStars,
/// `module_id = "topocentric"` — capa "ascensional": planetas
/// re-proyectados a longitud eclíptica topocéntrica (con paralaje
/// horizontal aplicada por cuerpo) + casas Polich-Page (sistema
/// topocéntrico de domificación). Visible sobre todo en la Luna
/// (~1° de shift); imperceptible en planetas exteriores. La capa
/// convive con la natal geocéntrica como overlay comparativo.
Topocentric,
/// `module_id = "pd_direct"` + `"pd_converse"` — Direcciones
/// Primarias del Sistema GR (García Rosas). Cada cuerpo natal se
/// proyecta dos veces: hacia adelante en el tiempo diurno
/// (direct) y hacia atrás (converse). Los dos resultados a la
/// edad pedida pintan un dual-ring para rectificación en vivo.
///
/// `key` controla la conversión arco↔año: "naibod" (default
/// moderno, 0°59'08.33″/año) o "ptolemy" (clásica, 1°/año).
PrimaryDirections {
target_age_years: f64,
key: String,
},
}
/// Opciones que afectan la pasada natal (qué aspectos pintar, qué
/// multiplicador de orbe usar). Es independiente de los overlays.
#[derive(Debug, Clone)]
pub struct NatalOptions {
/// Incluir aspectos mayores (conj/opp/trine/square/sextile).
pub show_majors: bool,
/// Incluir aspectos menores (quincunx/semi-sextile/etc).
pub show_minors: bool,
/// Multiplicador uniforme sobre los orbes default. `1.0` = orbes
/// modern_western; `0.5` = tight; `2.0` = wide.
pub orb_multiplier: f64,
/// Si `true`, anota cada cuerpo natal con su dignidad esencial
/// (domicilio +, exaltación ·, exilio , caída *). El canvas lo
/// renderea como sufijo del glifo.
pub show_dignities: bool,
}
impl Default for NatalOptions {
fn default() -> Self {
Self {
show_majors: true,
show_minors: false,
orb_multiplier: 1.0,
show_dignities: false,
}
}
}
/// Composición canónica: carta natal + todos los overlays pedidos.
/// Equivalente a `compose_with_options` con `NatalOptions::default()`.
pub fn compose(
chart: &Chart,
offset_minutes: i64,
requests: &[PipelineRequest],
) -> Result<RenderModel, EngineError> {
compose_with_options(chart, offset_minutes, requests, &NatalOptions::default())
}
/// Variante que permite controlar qué aspectos natales se computan y
/// con qué multiplicador de orbe.
pub fn compose_with_options(
chart: &Chart,
offset_minutes: i64,
requests: &[PipelineRequest],
natal_options: &NatalOptions,
) -> Result<RenderModel, EngineError> {
#[cfg(feature = "eternal-bridge")]
{
bridge::compose(chart, offset_minutes, requests, natal_options)
}
#[cfg(not(feature = "eternal-bridge"))]
{
let _ = (offset_minutes, requests, natal_options);
Ok(compute_mock(chart))
}
}
/// Atajo: natal sin overlays. Equivalente a `compose(chart, 0, &[])`.
pub fn compute(chart: &Chart) -> Result<RenderModel, EngineError> {
compose(chart, 0, &[])
}
/// Atajo: natal con time-scrubbing pero sin overlays.
pub fn compute_at_offset(chart: &Chart, offset_minutes: i64) -> Result<RenderModel, EngineError> {
compose(chart, offset_minutes, &[])
}
/// Atajo: natal + overlay de tránsitos al instante actual.
pub fn compute_with_transits_at_now(
chart: &Chart,
offset_minutes: i64,
) -> Result<RenderModel, EngineError> {
compose(chart, offset_minutes, &[PipelineRequest::Transit])
}
/// Computa la carta del retorno planetario actual (cuerpo + edad)
/// como `StoredBirthData` standalone — la app la usa para crear
/// una `FreeChart` que el usuario puede después persistir en un
/// contacto. Devuelve también un label-corto del instante para
/// concatenar al nombre.
#[cfg(feature = "eternal-bridge")]
pub fn compute_planetary_return_chart(
chart: &Chart,
body: &str,
target_age_years: f64,
shift_days: i64,
) -> Result<(cosmobiologia_model::StoredBirthData, String), EngineError> {
bridge::compute_planetary_return_chart(chart, body, target_age_years, shift_days)
}
/// Helper análogo para tránsito — birth_data = `ahora` UTC + lugar
/// del natal. Útil para snapshotear el cielo en este instante anclado
/// a las coordenadas del sujeto.
#[cfg(feature = "eternal-bridge")]
pub fn compute_transit_chart(
chart: &Chart,
) -> Result<(cosmobiologia_model::StoredBirthData, String), EngineError> {
bridge::compute_transit_chart(chart)
}
/// Helper análogo para progresión secundaria — birth_data = natal +
/// target_age_years × 1 día simbólico.
#[cfg(feature = "eternal-bridge")]
pub fn compute_progression_chart(
chart: &Chart,
target_age_years: f64,
) -> Result<(cosmobiologia_model::StoredBirthData, String), EngineError> {
bridge::compute_progression_chart(chart, target_age_years)
}
/// Helper retrocompatible: construye un `PlanetaryReturn` con
/// `shift_days = 0`. Útil para llamadores que no necesitan ajuste
/// fino (todos los Solar return y muchos casos básicos).
pub fn planetary_return_request(body: String, target_age_years: f64) -> PipelineRequest {
PipelineRequest::PlanetaryReturn {
body,
target_age_years,
shift_days: 0,
}
}
/// Stub determinista — útil para tests + para la UI sin eternal.
pub fn compute_mock(chart: &Chart) -> RenderModel {
use std::time::Instant;
let t0 = Instant::now();
let sign_dial = Layer {
module_id: "natal".into(),
kind: LayerKind::SignDial,
ring: 1.0,
z: 0,
geometry: Geometry::Ring {
cusps_deg: (0..12).map(|i| (i as f32) * 30.0).collect(),
},
glyphs: (0..12)
.map(|i| Glyph {
deg: (i as f32) * 30.0 + 15.0,
symbol: ZODIAC_GLYPHS[i].into(),
annotation: None,
retrograde: false,
house: None,
dignity_marker: None,
})
.collect(),
};
RenderModel {
chart_id: chart.id,
chart_kind: chart.kind,
title: chart.label.clone(),
subtitle: chart.birth_data.birthplace_label.clone(),
compute_ms: t0.elapsed().as_millis() as u64,
ascendant_deg: 0.0,
midheaven_deg: 270.0,
descendant_deg: 180.0,
imum_coeli_deg: 90.0,
layers: vec![sign_dial],
overlays: Vec::new(),
aspect_summary: Vec::new(),
uranian_groups: Vec::new(),
}
}
const ZODIAC_GLYPHS: [&str; 12] = [
"aries",
"taurus",
"gemini",
"cancer",
"leo",
"virgo",
"libra",
"scorpio",
"sagittarius",
"capricorn",
"aquarius",
"pisces",
];
// =====================================================================
// Tests
// =====================================================================
#[cfg(test)]
mod tests {
use super::*;
use cosmobiologia_model::{
Chart, ChartKind, ContactId, StoredBirthData, StoredChartConfig,
};
fn sample_chart() -> Chart {
Chart {
id: ChartId::new(),
contact_id: ContactId::new(),
kind: ChartKind::Natal,
label: "test".into(),
birth_data: StoredBirthData {
year: 1987,
month: 3,
day: 14,
hour: 5,
minute: 22,
second: 0.0,
tz_offset_minutes: -240,
latitude_deg: 10.4806,
longitude_deg: -66.9036,
altitude_m: 900.0,
time_certainty: Default::default(),
subject_name: None,
birthplace_label: None,
},
config: StoredChartConfig::default(),
related_chart_id: None,
created_at_ms: 0,
}
}
#[test]
fn mock_emits_sign_dial() {
let model = compute_mock(&sample_chart());
assert_eq!(model.layers.len(), 1);
assert!(matches!(model.layers[0].kind, LayerKind::SignDial));
assert_eq!(model.layers[0].glyphs.len(), 12);
}
#[cfg(feature = "eternal-bridge")]
#[test]
fn real_compute_natal_demo() {
let model = compute(&sample_chart()).expect("compute con eternal");
assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::SignDial)));
assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::Houses)));
assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::Bodies)));
// El Asc debe ser un grado válido.
assert!(model.ascendant_deg.is_finite());
assert!((0.0..360.0).contains(&model.ascendant_deg));
}
/// El cache de NatalChart debe hacer que la segunda llamada con
/// inputs idénticos sea sustancialmente más rápida que la primera.
/// Verificamos un piso del 4× — en práctica el ratio suele ser
/// >10× porque la primera carga VSOP2013 también.
#[cfg(feature = "eternal-bridge")]
#[test]
fn natal_cache_hits_are_faster() {
let chart = sample_chart();
// Warmup: abre la sesión de efemérides y puebla el cache.
let _ = compute(&chart).expect("warmup");
// Reset implícito: insertar una clave distinta no botaría la
// nuestra (cap=8) pero la marcaría como más vieja. Como solo
// tenemos 1 entrada, sigue al frente.
let t1 = std::time::Instant::now();
let _ = compute(&chart).expect("primera medida");
let cold_or_hot_1 = t1.elapsed();
let t2 = std::time::Instant::now();
let _ = compute(&chart).expect("segunda medida");
let hot = t2.elapsed();
// Después del warmup, las dos llamadas son hot. Para validar el
// efecto del cache, modificamos el offset_minutes para forzar
// un MISS y comparar contra un HIT.
use crate::PipelineRequest;
let t3 = std::time::Instant::now();
let _ = compose(&chart, 17, &[] as &[PipelineRequest])
.expect("miss con offset distinto");
let miss = t3.elapsed();
let t4 = std::time::Instant::now();
let _ = compose(&chart, 17, &[] as &[PipelineRequest])
.expect("hit con mismo offset");
let hit = t4.elapsed();
// Sanity: el hit debe ser estrictamente más rápido que el miss.
assert!(
hit < miss,
"cache hit ({:?}) debería ser más rápido que miss ({:?}); \
warmup={:?}, repeat={:?}",
hit, miss, cold_or_hot_1, hot
);
}
}
@@ -0,0 +1,116 @@
//! LRU cache para `NatalChart` por contenido.
//!
//! `NatalChart::compute` cuesta varios ms (VSOP2013 + casas + aspectos
//! base). En el shell, mover el slider de orbe o tocar un toggle
//! dispara un `compose()` completo donde la **misma** carta natal del
//! sujeto principal se recomputa idéntica. Lo mismo pasa con el partner
//! de Synastry / Composite — cada drag de slider rearma `partner_natal`.
//!
//! 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
//! editar una carta invalida automáticamente su entrada.
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex, OnceLock};
use eternal_astrology::NatalChart;
use cosmobiologia_model::{StoredBirthData, StoredChartConfig};
const CAPACITY: usize = 8;
type Key = u64;
struct Cache {
/// Front = más reciente, back = más viejo. `VecDeque` simple — con
/// cap 8 el search lineal cuesta menos que un HashMap.
entries: Vec<(Key, Arc<NatalChart>)>,
}
impl Cache {
fn new() -> Self {
Self {
entries: Vec::with_capacity(CAPACITY),
}
}
fn get(&mut self, k: Key) -> Option<Arc<NatalChart>> {
let idx = self.entries.iter().position(|(kk, _)| *kk == k)?;
// Move-to-front para mantener LRU.
let hit = self.entries.remove(idx);
let chart = hit.1.clone();
self.entries.insert(0, hit);
Some(chart)
}
fn put(&mut self, k: Key, v: Arc<NatalChart>) {
// Si ya existe la entrada (race: dos threads computaron lo mismo
// antes de poblar), reemplaza in-place.
if let Some(idx) = self.entries.iter().position(|(kk, _)| *kk == k) {
self.entries.remove(idx);
}
self.entries.insert(0, (k, v));
if self.entries.len() > CAPACITY {
self.entries.pop();
}
}
}
static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
fn cache() -> &'static Mutex<Cache> {
CACHE.get_or_init(|| Mutex::new(Cache::new()))
}
/// Hash de contenido: incluye todos los campos relevantes para el
/// cómputo de la carta natal. `f64` se hashea via `to_bits` para evitar
/// el `Hash` ausente de los flotantes.
pub fn key_for(
birth: &StoredBirthData,
config: &StoredChartConfig,
offset_minutes: i64,
) -> u64 {
let mut h = DefaultHasher::new();
// Birth data — fecha/hora/lugar.
birth.year.hash(&mut h);
birth.month.hash(&mut h);
birth.day.hash(&mut h);
birth.hour.hash(&mut h);
birth.minute.hash(&mut h);
birth.second.to_bits().hash(&mut h);
birth.tz_offset_minutes.hash(&mut h);
birth.latitude_deg.to_bits().hash(&mut h);
birth.longitude_deg.to_bits().hash(&mut h);
birth.altitude_m.to_bits().hash(&mut h);
// Config — todos los toggles que afectan el cómputo de placements y
// casas. Los enums derivan Debug; reusamos eso para hashear sin
// forzarles `Hash` manualmente.
format!("{:?}", config.house_system).hash(&mut h);
format!("{:?}", config.zodiac).hash(&mut h);
config.ayanamsha.hash(&mut h);
config.bodies.hash(&mut h);
config.include_south_node.hash(&mut h);
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);
h.finish()
}
/// Consulta. Devuelve `None` en miss; el caller debe computar y llamar
/// a `insert`.
pub fn get(k: Key) -> Option<Arc<NatalChart>> {
cache().lock().ok()?.get(k)
}
/// Inserta una entrada. Idempotente: re-insertar la misma key la mueve
/// al frente.
pub fn insert(k: Key, v: Arc<NatalChart>) {
if let Ok(mut guard) = cache().lock() {
guard.put(k, v);
}
}
@@ -0,0 +1,319 @@
//! Export del `RenderModel` a SVG.
//!
//! Genera un documento SVG standalone con la misma geometría que pinta
//! el canvas: anillos zodiacales, cusps, planetas, aspectos. El
//! resultado es escalable (imprimible a cualquier tamaño) y no requiere
//! la app GPUI para verse — cualquier visor de SVG sirve.
//!
//! Convención de coordenadas idéntica al canvas:
//! `screen_angle_deg = 180 - (longitude - ascendant)` con +y para abajo.
use std::f64::consts::PI;
use std::fmt::Write;
use crate::{Geometry, LayerKind, RenderModel};
/// Dimensiones default del viewport. Aspect ratio cuadrada.
const VIEWBOX: f64 = 800.0;
const MARGIN: f64 = 40.0;
/// Radios normalizados — espejan los de `cosmobiologia-canvas`.
const R_SIGN_OUTER: f64 = 1.00;
const R_SIGN_INNER: f64 = 0.88;
const R_TRANSITS: f64 = 0.82;
const R_HOUSES_OUTER: f64 = 0.78;
const R_HOUSES_INNER: f64 = 0.66;
const R_BODIES: f64 = 0.58;
const R_PROGRESSION: f64 = 0.48;
const R_SOLAR_ARC: f64 = 0.40;
const R_ASPECTS: f64 = 0.32;
/// Convierte el `RenderModel` a un documento SVG completo.
pub fn render_to_svg(render: &RenderModel) -> String {
let mut out = String::with_capacity(8192);
let r_outer = (VIEWBOX - MARGIN * 2.0) / 2.0;
let cx = VIEWBOX / 2.0;
let cy = VIEWBOX / 2.0;
let asc = render.ascendant_deg as f64;
writeln!(
out,
r#"<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {0} {1}" width="{0}" height="{1}" font-family="serif" text-anchor="middle" dominant-baseline="central">"#,
VIEWBOX, VIEWBOX
)
.unwrap();
// Fondo + título.
writeln!(
out,
r##" <rect x="0" y="0" width="{0}" height="{0}" fill="#fdfaf3"/>
<text x="{cx}" y="20" font-size="14" fill="#2a2620">{title}</text>"##,
VIEWBOX,
cx = cx,
title = escape_xml(&render.title)
)
.unwrap();
// Anillos base.
for r in [R_SIGN_OUTER, R_SIGN_INNER, R_HOUSES_OUTER, R_HOUSES_INNER] {
writeln!(
out,
r##" <circle cx="{cx}" cy="{cy}" r="{r}" fill="none" stroke="#a89572" stroke-width="0.6"/>"##,
r = r * r_outer
)
.unwrap();
}
// Cusps del zodíaco cada 30°.
for i in 0..12 {
let lon = (i as f64) * 30.0;
let (x1, y1) = polar(lon, asc, R_SIGN_INNER * r_outer, cx, cy);
let (x2, y2) = polar(lon, asc, R_SIGN_OUTER * r_outer, cx, cy);
writeln!(
out,
r##" <line x1="{x1:.2}" y1="{y1:.2}" x2="{x2:.2}" y2="{y2:.2}" stroke="#a89572" stroke-width="0.5"/>"##,
)
.unwrap();
}
// Glifos de signos a media-altura del dial.
let sign_mid = (R_SIGN_OUTER + R_SIGN_INNER) / 2.0;
for layer in &render.layers {
if matches!(layer.kind, LayerKind::SignDial) {
for g in &layer.glyphs {
let (x, y) = polar(g.deg as f64, asc, sign_mid * r_outer, cx, cy);
writeln!(
out,
r##" <text x="{x:.2}" y="{y:.2}" font-size="16" fill="#5a4830">{}</text>"##,
sign_unicode(&g.symbol)
)
.unwrap();
}
}
}
// Cusps de casas + énfasis Asc/IC/Desc/MC.
for layer in &render.layers {
if matches!(layer.kind, LayerKind::Houses) {
if let Geometry::Ring { cusps_deg } = &layer.geometry {
for (i, c) in cusps_deg.iter().enumerate() {
let is_angle = i == 0 || i == 3 || i == 6 || i == 9;
let (color, w) = if is_angle {
("#b8862e", 1.6)
} else {
("#9b8460", 0.5)
};
let (x1, y1) =
polar(*c as f64, asc, R_HOUSES_INNER * r_outer, cx, cy);
let (x2, y2) =
polar(*c as f64, asc, R_HOUSES_OUTER * r_outer, cx, cy);
writeln!(
out,
r##" <line x1="{x1:.2}" y1="{y1:.2}" x2="{x2:.2}" y2="{y2:.2}" stroke="{color}" stroke-width="{w}"/>"##,
)
.unwrap();
}
}
}
}
// Líneas de aspectos. Para natal usamos un solo ring; para
// cross-aspects (transit/synastry/progression/solar_arc/...) los
// extremos van en rings distintos según el `module_id`.
for layer in &render.layers {
if !matches!(layer.kind, LayerKind::Aspects) {
continue;
}
if let Geometry::Lines(segs) = &layer.geometry {
let (r_from, r_to) = aspect_radii(&layer.module_id);
for seg in segs {
let color = aspect_color_hex(&seg.kind);
let (x1, y1) = polar(seg.from_deg as f64, asc, r_from * r_outer, cx, cy);
let (x2, y2) = polar(seg.to_deg as f64, asc, r_to * r_outer, cx, cy);
writeln!(
out,
r##" <line x1="{x1:.2}" y1="{y1:.2}" x2="{x2:.2}" y2="{y2:.2}" stroke="{color}" stroke-width="0.6" stroke-opacity="{op:.2}"/>"##,
op = seg.opacity
)
.unwrap();
}
}
}
// Glifos planetarios (natal + overlays). Cada uno en su ring.
for layer in &render.layers {
if !matches!(layer.kind, LayerKind::Bodies | LayerKind::Outer) {
continue;
}
let ring = body_ring_radius(&layer.module_id);
let size = if layer.module_id == "natal" { 18 } else { 14 };
for g in &layer.glyphs {
let (x, y) = polar(g.deg as f64, asc, ring * r_outer, cx, cy);
let glyph = planet_unicode(&g.symbol);
let suffix = match (g.retrograde, g.dignity_marker.as_deref()) {
(true, Some(m)) => format!("ᴿ{}", m),
(true, None) => "ᴿ".into(),
(false, Some(m)) => m.to_string(),
(false, None) => String::new(),
};
writeln!(
out,
r##" <text x="{x:.2}" y="{y:.2}" font-size="{size}" fill="#1f1812">{glyph}{suffix}</text>"##
)
.unwrap();
}
}
// Etiquetas ASC / MC / DESC / IC en el perímetro.
for (deg, label) in [
(asc, "ASC"),
(render.midheaven_deg as f64, "MC"),
(render.descendant_deg as f64, "DESC"),
(render.imum_coeli_deg as f64, "IC"),
] {
let (x, y) = polar(deg, asc, 1.06 * r_outer, cx, cy);
writeln!(
out,
r##" <text x="{x:.2}" y="{y:.2}" font-size="10" fill="#b8862e">{label}</text>"##
)
.unwrap();
}
writeln!(out, "</svg>").unwrap();
out
}
fn polar(longitude_deg: f64, ascendant_deg: f64, radius: f64, cx: f64, cy: f64) -> (f64, f64) {
let deg = 180.0 - (longitude_deg - ascendant_deg);
let rad = deg * PI / 180.0;
(cx + radius * rad.cos(), cy + radius * rad.sin())
}
fn aspect_radii(module_id: &str) -> (f64, f64) {
if crate::OUTER_RING_MODULES.contains(&module_id) {
return (R_BODIES, R_TRANSITS);
}
match module_id {
"progression" => (R_BODIES, R_PROGRESSION),
"solar_arc" => (R_BODIES, R_SOLAR_ARC),
_ => (R_ASPECTS, R_ASPECTS),
}
}
fn body_ring_radius(module_id: &str) -> f64 {
if crate::OUTER_RING_MODULES.contains(&module_id) {
return R_TRANSITS;
}
match module_id {
"progression" => R_PROGRESSION,
"solar_arc" => R_SOLAR_ARC,
_ => R_BODIES,
}
}
fn sign_unicode(name: &str) -> &'static str {
match name {
"aries" => "",
"taurus" => "",
"gemini" => "",
"cancer" => "",
"leo" => "",
"virgo" => "",
"libra" => "",
"scorpio" => "",
"sagittarius" => "",
"capricorn" => "",
"aquarius" => "",
"pisces" => "",
_ => "?",
}
}
fn planet_unicode(name: &str) -> &'static str {
match name {
"sun" => "",
"moon" => "",
"mercury" => "",
"venus" => "",
"mars" => "",
"jupiter" => "",
"saturn" => "",
"uranus" => "",
"neptune" => "",
"pluto" => "",
"north_node" => "",
"south_node" => "",
"chiron" => "",
"lilith" => "",
"ceres" => "",
"pallas" => "",
"juno" => "",
"vesta" => "",
_ => "",
}
}
fn aspect_color_hex(kind: &str) -> &'static str {
match kind {
"conjunction" => "#b8862e",
"opposition" => "#a64a8a",
"trine" => "#3f7d57",
"square" => "#c64b2a",
"sextile" => "#3a6db5",
_ => "#8a7660",
}
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{compute_mock, ChartKind};
use cosmobiologia_model::{Chart, ContactId, StoredBirthData, StoredChartConfig};
fn sample_chart() -> Chart {
Chart {
id: cosmobiologia_model::ChartId::new(),
contact_id: ContactId::new(),
kind: ChartKind::Natal,
label: "Test".into(),
birth_data: StoredBirthData {
year: 1987,
month: 3,
day: 14,
hour: 5,
minute: 22,
second: 0.0,
tz_offset_minutes: -240,
latitude_deg: 10.0,
longitude_deg: -66.0,
altitude_m: 0.0,
time_certainty: Default::default(),
subject_name: None,
birthplace_label: None,
},
config: StoredChartConfig::default(),
related_chart_id: None,
created_at_ms: 0,
}
}
#[test]
fn svg_well_formed_minimal() {
let render = compute_mock(&sample_chart());
let svg = render_to_svg(&render);
assert!(svg.starts_with("<?xml"));
assert!(svg.contains("<svg"));
assert!(svg.ends_with("</svg>\n"));
// Debe traer al menos un círculo de los rings base.
assert!(svg.contains("<circle "));
}
}