feat(cosmobiologia): GR — cómputo de triggers y eventos de rectificación

Primer incremento del Sistema GR (García Rosas): la engine, además del
dual-ring directo/converso, ahora computa los triggers de rectificación
y detecta las convergencias directo+converso sobre un mismo punto natal.

- cosmobiologia-render: módulo `gr` agnóstico — tipos GrTrigger/GrDirection
  + compute_gr_triggers (emparejamiento puro, 7 tests). Campo gr_triggers
  en RenderModel (serde-default, back-compat).
- cosmobiologia-engine: build_primary_directions_overlay computa los
  triggers contra cuerpos natales + 4 ángulos; orbe HUD 2°, micro-orbe
  de evento 5'. Test end-to-end con eternal.

Falta: resaltado del evento en el canvas, HUD lateral, scrubbing live.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-22 13:40:09 +00:00
parent e77a32f4d6
commit 15e45ace9b
4 changed files with 363 additions and 32 deletions
@@ -0,0 +1,245 @@
//! Sistema GR (García Rosas) — detección de *triggers* de rectificación.
//!
//! Un trigger GR es un cuerpo natal proyectado por dirección primaria
//! —directa o conversa— que cae cerca de un punto natal. La
//! rectificación horaria se valida observando estos contactos: un
//! evento real de la vida del sujeto debe coincidir con un trigger
//! ajustado si la hora natal es correcta.
//!
//! Cuando un mismo punto natal recibe a la vez un trigger directo y
//! otro converso dentro del micro-orbe de evento, hay una
//! **convergencia GR**: la señal fuerte de rectificación.
//!
//! Esta lógica es pura: el engine computa las longitudes dirigidas
//! (eso sí necesita `eternal-astrology`) y delega aquí el
//! emparejamiento contra los puntos natales. Así la parte que define
//! *qué cuenta como trigger* vive en un crate liviano y testeable.
use serde::{Deserialize, Serialize};
/// Dirección de una proyección primaria del Sistema GR.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GrDirection {
/// Directa — rotación diurna hacia adelante en el tiempo.
Direct,
/// Conversa — rotación diurna inversa.
Converse,
}
impl GrDirection {
/// Etiqueta de una letra para el HUD (`D` / `C`).
pub fn short(self) -> &'static str {
match self {
GrDirection::Direct => "D",
GrDirection::Converse => "C",
}
}
/// Etiqueta legible.
pub fn label(self) -> &'static str {
match self {
GrDirection::Direct => "directa",
GrDirection::Converse => "conversa",
}
}
}
/// Un contacto del Sistema GR: un cuerpo promisor dirigido que cae
/// cerca de un punto natal.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GrTrigger {
/// Símbolo del cuerpo promisor (el que se dirige). Ej. `"mars"`.
pub promissor: String,
/// Si la proyección es directa o conversa.
pub direction: GrDirection,
/// Punto natal contactado: símbolo de cuerpo (`"sun"`) o ángulo
/// (`"asc"`, `"mc"`, `"desc"`, `"ic"`).
pub natal_target: String,
/// Longitud eclíptica [0,360) del punto natal contactado.
pub natal_deg: f32,
/// Longitud eclíptica [0,360) donde cayó el promisor dirigido.
pub directed_deg: f32,
/// Orbe absoluto del contacto, en grados (separación circular).
pub orb_deg: f32,
/// `true` si el trigger forma parte de una convergencia GR
/// (directo + converso sobre el mismo punto natal, ambos dentro
/// del micro-orbe de evento). La UI lo resalta.
#[serde(default)]
pub event: bool,
}
/// Separación circular mínima entre dos longitudes eclípticas, en
/// grados (rango `0..=180`).
fn circular_sep(a: f32, b: f32) -> f32 {
let d = (a - b).rem_euclid(360.0);
d.min(360.0 - d)
}
/// Empareja cada posición dirigida contra cada punto natal y produce
/// la lista de triggers GR.
///
/// - `directed`: `(promisor, dirección, longitud_dirigida)`.
/// - `natal_targets`: `(nombre, longitud_natal)`.
/// - `hud_orb_deg`: orbe máximo para que un contacto entre a la lista.
/// - `event_orb_deg`: micro-orbe de convergencia (ver [`mark_events`]).
/// - `max_triggers`: tope de la lista tras ordenar por orbe.
///
/// El resultado va ordenado por `orb_deg` ascendente (los contactos
/// más cerrados primero) y truncado a `max_triggers`.
pub fn compute_gr_triggers(
directed: &[(String, GrDirection, f32)],
natal_targets: &[(String, f32)],
hud_orb_deg: f32,
event_orb_deg: f32,
max_triggers: usize,
) -> Vec<GrTrigger> {
let mut triggers = Vec::new();
for (promissor, direction, raw_directed) in directed {
let directed_deg = raw_directed.rem_euclid(360.0);
for (name, raw_natal) in natal_targets {
let natal_deg = raw_natal.rem_euclid(360.0);
let orb = circular_sep(directed_deg, natal_deg);
if orb <= hud_orb_deg {
triggers.push(GrTrigger {
promissor: promissor.clone(),
direction: *direction,
natal_target: name.clone(),
natal_deg,
directed_deg,
orb_deg: orb,
event: false,
});
}
}
}
mark_events(&mut triggers, event_orb_deg);
triggers.sort_by(|a, b| {
a.orb_deg
.partial_cmp(&b.orb_deg)
.unwrap_or(core::cmp::Ordering::Equal)
});
triggers.truncate(max_triggers);
triggers
}
/// Marca como `event` los triggers que forman una convergencia GR: un
/// mismo punto natal tocado por un trigger directo y otro converso,
/// ambos dentro de `event_orb_deg`.
fn mark_events(triggers: &mut [GrTrigger], event_orb_deg: f32) {
use std::collections::HashSet;
let mut has_direct: HashSet<String> = HashSet::new();
let mut has_converse: HashSet<String> = HashSet::new();
for t in triggers.iter() {
if t.orb_deg <= event_orb_deg {
match t.direction {
GrDirection::Direct => {
has_direct.insert(t.natal_target.clone());
}
GrDirection::Converse => {
has_converse.insert(t.natal_target.clone());
}
}
}
}
for t in triggers.iter_mut() {
if t.orb_deg <= event_orb_deg
&& has_direct.contains(&t.natal_target)
&& has_converse.contains(&t.natal_target)
{
t.event = true;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn d(promissor: &str, dir: GrDirection, deg: f32) -> (String, GrDirection, f32) {
(promissor.to_string(), dir, deg)
}
#[test]
fn contact_within_hud_orb_becomes_a_trigger() {
let directed = vec![d("mars", GrDirection::Direct, 101.5)];
let targets = vec![("sun".to_string(), 100.0)];
let out = compute_gr_triggers(&directed, &targets, 2.0, 0.083, 60);
assert_eq!(out.len(), 1);
assert_eq!(out[0].promissor, "mars");
assert_eq!(out[0].natal_target, "sun");
assert!((out[0].orb_deg - 1.5).abs() < 1e-3);
assert!(!out[0].event);
}
#[test]
fn contact_beyond_hud_orb_is_dropped() {
let directed = vec![d("mars", GrDirection::Direct, 103.0)];
let targets = vec![("sun".to_string(), 100.0)];
assert!(compute_gr_triggers(&directed, &targets, 2.0, 0.083, 60).is_empty());
}
#[test]
fn direct_and_converse_within_micro_orb_form_an_event() {
// Marte directo y Venus converso, ambos sobre el Sol natal a
// <5' de orbe: convergencia GR.
let directed = vec![
d("mars", GrDirection::Direct, 100.04),
d("venus", GrDirection::Converse, 99.97),
];
let targets = vec![("sun".to_string(), 100.0)];
let out = compute_gr_triggers(&directed, &targets, 2.0, 5.0 / 60.0, 60);
assert_eq!(out.len(), 2);
assert!(out.iter().all(|t| t.event), "ambos triggers son evento");
}
#[test]
fn lone_direct_within_micro_orb_is_not_an_event() {
// Un solo toque directo, sin converso: no hay convergencia.
let directed = vec![
d("mars", GrDirection::Direct, 100.02),
d("venus", GrDirection::Direct, 99.98),
];
let targets = vec![("sun".to_string(), 100.0)];
let out = compute_gr_triggers(&directed, &targets, 2.0, 5.0 / 60.0, 60);
assert!(out.iter().all(|t| !t.event), "sin converso no hay evento");
}
#[test]
fn converging_pair_must_share_the_same_natal_target() {
// Directo sobre el Sol, converso sobre la Luna: no convergen.
let directed = vec![
d("mars", GrDirection::Direct, 100.01),
d("venus", GrDirection::Converse, 200.01),
];
let targets = vec![("sun".to_string(), 100.0), ("moon".to_string(), 200.0)];
let out = compute_gr_triggers(&directed, &targets, 2.0, 5.0 / 60.0, 60);
assert!(out.iter().all(|t| !t.event));
}
#[test]
fn results_are_sorted_by_orb_and_capped() {
let directed = vec![
d("a", GrDirection::Direct, 101.8),
d("b", GrDirection::Direct, 100.3),
d("c", GrDirection::Direct, 101.0),
];
let targets = vec![("sun".to_string(), 100.0)];
let out = compute_gr_triggers(&directed, &targets, 2.0, 0.083, 2);
assert_eq!(out.len(), 2, "truncado a max_triggers");
assert!(out[0].orb_deg <= out[1].orb_deg, "ordenado por orbe");
assert_eq!(out[0].promissor, "b", "el más cerrado primero");
}
#[test]
fn circular_sep_handles_wraparound() {
// 359° y 1° están a 2°, no a 358°.
let directed = vec![d("mars", GrDirection::Direct, 359.0)];
let targets = vec![("asc".to_string(), 1.0)];
let out = compute_gr_triggers(&directed, &targets, 3.0, 0.083, 60);
assert_eq!(out.len(), 1);
assert!((out[0].orb_deg - 2.0).abs() < 1e-3);
}
}
@@ -31,12 +31,14 @@ use serde::{Deserialize, Serialize};
pub use cosmobiologia_model::{Chart, ChartId, ChartKind};
pub mod draw;
pub mod gr;
pub mod math;
pub mod palette;
pub use draw::{
compose_wheel, draw_commands_to_svg, CompositionOpts, DrawCommand, Rgba, TextAnchor,
};
pub use gr::{compute_gr_triggers, GrDirection, GrTrigger};
pub use math::{
find_clusters, format_coord_compact, polar_to_screen, spread_angles, Radii,
};
@@ -82,6 +84,12 @@ pub struct RenderModel {
/// Vacío sino se activó el módulo Uranian.
#[serde(default)]
pub uranian_groups: Vec<UranianGroup>,
/// Triggers del Sistema GR (direcciones primarias). Poblado sólo
/// cuando el módulo `primary_directions` está activo; ordenado por
/// `orb_deg` ascendente. La UI lo lista en el HUD de rectificación
/// y resalta los `event = true` (convergencias directo+converso).
#[serde(default)]
pub gr_triggers: Vec<GrTrigger>,
}
/// Etiqueta legible de un overlay para el footer del canvas. La engine