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,12 @@
[package]
name = "cosmobiologia-model"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "Tahuantinsuyu — tipos agnósticos del modelo astrológico (Group, Contact, Chart, StoredBirthData, StoredChartConfig)."
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
ulid = { workspace = true }
@@ -0,0 +1,394 @@
//! `cosmobiologia-model` — tipos agnósticos del estudio astrológico.
//!
//! Esta es la capa de **datos puros**: no conoce GPUI, ni rusqlite, ni
//! `eternal-astrology`. Solo tipos `serde`-able que viajan entre la
//! store, la engine, los widgets, y eventualmente la Card de Brahman.
//!
//! ## Jerarquía
//!
//! ```text
//! Group (puede anidar otros Groups vía parent_id)
//! ├── Group (sub-agrupación)
//! └── Contact (persona / evento / lugar)
//! └── Chart (carta astrológica)
//! ```
//!
//! Las `Chart` son las hojas — cada una guarda su `StoredBirthData` y su
//! `StoredChartConfig`. La engine las traduce a tipos de `eternal-astrology`
//! cuando hay que computar.
//!
//! ## Por qué tipos "Stored" propios y no reusar `eternal-astrology`
//!
//! Forward-compat: si mañana cambia el shape de `BirthData` upstream, o
//! queremos persistir en otro backend astronómico, el modelo + la base
//! sobreviven. La engine es el único puente que conoce ambas formas.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
use serde::{Deserialize, Serialize};
use thiserror::Error;
use ulid::Ulid;
pub use ::ulid;
// =====================================================================
// Identidades
// =====================================================================
macro_rules! ulid_newtype {
($name:ident, $doc:expr) => {
#[doc = $doc]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct $name(pub Ulid);
impl $name {
pub fn new() -> Self {
Self(Ulid::new())
}
}
impl Default for $name {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl std::str::FromStr for $name {
type Err = ulid::DecodeError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ulid::from_string(s).map(Self)
}
}
};
}
ulid_newtype!(GroupId, "Identificador estable de un Group.");
ulid_newtype!(ContactId, "Identificador estable de un Contact.");
ulid_newtype!(ChartId, "Identificador estable de un Chart.");
// =====================================================================
// Group / Contact
// =====================================================================
/// Agrupación jerárquica de contactos. Puede anidar otros groups vía
/// `parent_id` (un Group raíz tiene `parent_id = None`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Group {
pub id: GroupId,
pub parent_id: Option<GroupId>,
pub name: String,
#[serde(default)]
pub description: Option<String>,
/// Epoch millis. Decisión: `i64` para tolerar valores pre-1970 en
/// imports históricos sin overflow.
pub created_at_ms: i64,
/// Orden manual dentro del padre. Más bajo = primero. Empate → por nombre.
#[serde(default)]
pub sort_order: i32,
}
/// Persona o evento del que se calcula una o más cartas. Puede vivir
/// directamente en la raíz (`group_id = None`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Contact {
pub id: ContactId,
pub group_id: Option<GroupId>,
pub name: String,
#[serde(default)]
pub notes: Option<String>,
pub created_at_ms: i64,
}
// =====================================================================
// Datos de nacimiento (espejo agnóstico de eternal_astrology::BirthData)
// =====================================================================
/// Datos crudos de nacimiento. La engine los traduce a
/// `eternal_astrology::BirthData` cuando hay que computar.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredBirthData {
/// Calendario civil local.
pub year: i32,
pub month: u32,
pub day: u32,
pub hour: u32,
pub minute: u32,
/// Segundos fraccionarios (0.0..60.0).
pub second: f64,
/// Offset desde UTC, en minutos. Ej: -240 = UTC-04:00.
pub tz_offset_minutes: i32,
/// Coordenadas geográficas en grados decimales.
pub latitude_deg: f64,
pub longitude_deg: f64,
/// Altura en metros sobre el geoide WGS-84.
#[serde(default)]
pub altitude_m: f64,
#[serde(default)]
pub time_certainty: TimeCertainty,
#[serde(default)]
pub subject_name: Option<String>,
#[serde(default)]
pub birthplace_label: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TimeCertainty {
#[default]
Exact,
RoundedHour,
RoundedDay,
Estimated,
}
// =====================================================================
// Configuración de carta (espejo agnóstico de eternal_astrology::ChartConfig)
// =====================================================================
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Zodiac {
#[default]
Tropical,
Sidereal,
Draconic,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HouseSystem {
#[default]
Placidus,
Koch,
Regiomontanus,
Campanus,
Porphyry,
Equal,
WholeSign,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredChartConfig {
#[serde(default)]
pub zodiac: Zodiac,
#[serde(default)]
pub house_system: HouseSystem,
/// Nombre del ayanamsha cuando `zodiac == Sidereal`. Ej: "lahiri",
/// "fagan_bradley". Ignorado para Tropical/Draconic.
#[serde(default)]
pub ayanamsha: Option<String>,
/// Cuerpos a incluir. Strings opacos para que el modelo no se ate
/// al enum `Body` de eternal. Ej: ["sun","moon","mercury",…].
#[serde(default = "default_bodies")]
pub bodies: Vec<String>,
#[serde(default = "default_true")]
pub include_south_node: bool,
#[serde(default)]
pub include_lilith: bool,
#[serde(default)]
pub include_main_belt_asteroids: bool,
#[serde(default)]
pub include_fixed_stars: bool,
/// Tabla de orbes a usar (nombre simbólico). `None` → orbes defaults
/// de la engine.
#[serde(default)]
pub orb_table: Option<String>,
}
impl Default for StoredChartConfig {
fn default() -> Self {
Self {
zodiac: Zodiac::default(),
house_system: HouseSystem::default(),
ayanamsha: None,
bodies: default_bodies(),
include_south_node: true,
include_lilith: false,
include_main_belt_asteroids: false,
include_fixed_stars: false,
orb_table: None,
}
}
}
fn default_bodies() -> Vec<String> {
vec![
"sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune",
"pluto", "mean_node",
]
.into_iter()
.map(String::from)
.collect()
}
fn default_true() -> bool {
true
}
// =====================================================================
// Chart
// =====================================================================
/// Tipo de carta astrológica. Determina qué rutina de la engine corre
/// y qué `Layer`s aporta al canvas.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChartKind {
Natal,
Transit,
SecondaryProgression,
TertiaryProgression,
MinorProgression,
SolarArc,
SolarReturn,
LunarReturn,
Synastry,
Composite,
Davison,
Profection,
PrimaryDirection,
/// Carta "mundial" para un instante + lugar sin sujeto natal.
Mundane,
}
impl ChartKind {
/// `true` si la carta necesita una segunda carta natal como referencia
/// (synastry/composite/davison). Útil para validar al persistir.
pub fn requires_related_chart(self) -> bool {
matches!(
self,
ChartKind::Synastry | ChartKind::Composite | ChartKind::Davison
)
}
}
/// Una carta concreta dentro de un contacto. Las cartas de tipo
/// derivado (transit, progression, synastry, …) referencian la carta
/// natal de la que parten vía `related_chart_id`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Chart {
pub id: ChartId,
pub contact_id: ContactId,
pub kind: ChartKind,
pub label: String,
pub birth_data: StoredBirthData,
pub config: StoredChartConfig,
/// Para cartas derivadas: la carta de referencia. Para transit/
/// progression apunta a la natal del mismo contacto. Para synastry
/// apunta a la carta del otro sujeto.
#[serde(default)]
pub related_chart_id: Option<ChartId>,
pub created_at_ms: i64,
}
// =====================================================================
// Estado de módulos por carta (qué capas están activas + su config)
// =====================================================================
/// Cada `ChartKind` puede activar uno o más `module_id` (ej. una carta
/// natal puede tener `natal`, `dignities`, `fixed_stars`, `uranian`).
/// El estado por-carta se persiste en la store; el canvas lo consulta
/// para decidir qué capas pintar y qué controles mostrar en el panel.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModuleState {
pub chart_id: ChartId,
pub module_id: String,
pub enabled: bool,
/// JSON libre — cada módulo define su schema.
#[serde(default)]
pub config: serde_json::Value,
}
// =====================================================================
// Selección activa (qué muestra el canvas)
// =====================================================================
/// Identificador de una carta "libre" — efímera, no persistida en la
/// store. Llave de un `HashMap` en el shell. El valor `SKY_NOW_ID`
/// está reservado para la carta del instante actual; otros se
/// generan al vuelo como UUIDs string-encoded.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FreeChartId(pub String);
impl FreeChartId {
pub fn sky_now() -> Self {
Self(SKY_NOW_ID.into())
}
pub fn is_sky_now(&self) -> bool {
self.0 == SKY_NOW_ID
}
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Sentinela del id de la carta "Cielo ahora" — siempre presente
/// como primer elemento de la sección "Cartas libres" del tree.
pub const SKY_NOW_ID: &str = "sky-now";
/// Item activo del tree. El canvas reacciona a este tipo:
/// - `Chart` → abre la carta puntual.
/// - `Contact` / `Group` → muestra thumbnails de las cartas descendientes.
/// - `FreeChart` → carta libre (no anclada a contacto). Incluye la
/// especial "Cielo ahora" + cualquier creada por el usuario.
/// - `FreeChartsRoot` → branch virtual de la sección "Cartas libres".
/// - `GeneralRoot` → nodo branch virtual que agrupa los contactos
/// sin grupo padre (contacts con parent=None).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum TreeSelection {
Group(GroupId),
Contact(ContactId),
Chart(ChartId),
FreeChart(FreeChartId),
FreeChartsRoot,
GeneralRoot,
}
// =====================================================================
// Errores
// =====================================================================
#[derive(Debug, Error)]
pub enum ModelError {
#[error("chart {kind:?} requiere related_chart_id pero recibió None")]
MissingRelatedChart { kind: ChartKind },
#[error("group {0} no puede ser su propio ancestro")]
GroupCycle(GroupId),
#[error("invalid field {field}: {reason}")]
InvalidField {
field: &'static str,
reason: String,
},
}
impl Chart {
/// Validación liviana: ataja errores que la base no captura
/// (ej. synastry sin `related_chart_id`).
pub fn validate(&self) -> Result<(), ModelError> {
if self.kind.requires_related_chart() && self.related_chart_id.is_none() {
return Err(ModelError::MissingRelatedChart { kind: self.kind });
}
if !(-90.0..=90.0).contains(&self.birth_data.latitude_deg) {
return Err(ModelError::InvalidField {
field: "latitude_deg",
reason: format!("{} fuera de [-90, 90]", self.birth_data.latitude_deg),
});
}
if !(-180.0..=180.0).contains(&self.birth_data.longitude_deg) {
return Err(ModelError::InvalidField {
field: "longitude_deg",
reason: format!("{} fuera de [-180, 180]", self.birth_data.longitude_deg),
});
}
Ok(())
}
}