gioser

tawasuyu/brahman

Rust 476TOML 190Markdown 50JSON 176602 Definiciones

⎇ 43e6b32e15dea99bd57a9a11fdafa7bd9a2a236f crates/modules/cosmobiologia/cosmobiologia-corpus/src/lib.rs Rust · 611 líneas · 23.0 KB Historia Quién tocó qué crudo

feat(cosmobiologia-corpus): tomografía por dominio + plantilla y guía 43e6b32e1 · sergio ·

1//! `cosmobiologia-corpus` — la biblioteca de interpretación, indexada.
2//!
3//! El corpus **no calcula nada** y **no es un set de reglas
4//! matemáticas**. Las reglas —qué planeta en qué signo, qué aspecto—
5//! las computa el motor astronómico (`cosmobiologia-engine`). El corpus
6//! es la **evidencia textual**: fragmentos de los libros —y de la
7//! escritura del propio astrólogo— recortados y etiquetados con la
8//! combinación exacta que describen. En runtime, las combinaciones de
9//! una carta hacen un JOIN contra el corpus y traen los textos —
10//! citados, con fuente, sin que ninguna IA invente una palabra.
11//!
12//! ## Estructura — con TIPOS, porque la astrología tiene gramática
13//!
14//! Un planeta es una FUNCIÓN; un signo, un ESTILO; una casa, un
15//! DOMINIO; un aspecto, una RELACIÓN. No son vectores intercambiables
16//! de un mismo espacio plano — colapsarlos a uno solo destruye el
17//! significado. El corpus respeta esa gramática:
18//!
19//! 1. **Arquetipos** ([`Arquetipo`]) — los bloques: cada planeta /
20//!    signo / casa / aspecto, con su [`PerfilSemantico`] (dimensiones
21//!    psicológicas con peso). Es la ontología que el astrólogo escribe.
22//! 2. **Pasajes** ([`Pasaje`]) — el corpus propiamente dicho: texto
23//!    real etiquetado por [`CombinacionId`], con su fuente. La
24//!    evidencia citable.
25//! 3. **Composición** — deducir el perfil de una combinación NO leída a
26//!    partir de los bloques. Es un problema de diseño **abierto**: un
27//!    producto Hadamard ingenuo da resultados falsos (la dimensión que
28//!    un bloque tiene en 0 se queda en 0, no «se enciende»). Este crate
29//!    trae las capas 1-2 y deja la 3 sin resolver a propósito.
30//!
31//! La **rebanada por dominio** —ver el cuerpo de la carta en tajadas—
32//! sí vive aquí ([`rebanar_por_dominio`]): es geometría sobre las
33//! claves, no síntesis. La carta es una sola configuración; cortarla
34//! por dominio vivencial no la promedia, la MIRA desde un plano. Lo
35//! único que queda fuera es la síntesis narrativa —tejer los pasajes
36//! recuperados en un texto continuo—, trabajo de una capa superior.
37
38#![forbid(unsafe_code)]
39
40use std::collections::BTreeMap;
41use std::str::FromStr;
42
43use serde::{Deserialize, Deserializer, Serialize, Serializer};
44
45/// Perfil semántico: dimensiones psicológicas/vivenciales con un peso,
46/// por convención en `[-1.0, 1.0]`. Los **nombres** de las dimensiones
47/// los define el astrólogo en los datos — el esquema NO los fija (no
48/// presupone "Acción", "Estructura", …: el modelo es decisión del
49/// astrólogo, no del código).
50pub type PerfilSemantico = BTreeMap<String, f32>;
51
52/// El rol gramatical de un arquetipo. No es decorativo: marca que
53/// planeta y signo NO son la misma clase de cosa, y por eso no se
54/// combinan con un operador único e indiferenciado.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum TipoArquetipo {
58    /// Una función psíquica (Marte = impulso, Mercurio = cognición…).
59    Planeta,
60    /// Un estilo o modo (el signo colorea CÓMO se expresa la función).
61    Signo,
62    /// Un dominio o arena de la vida (la casa dice DÓNDE opera).
63    Casa,
64    /// Una relación entre dos funciones (conjunción, cuadratura…).
65    Aspecto,
66}
67
68/// Un bloque constructor: un planeta, signo, casa o aspecto, con el
69/// perfil semántico que el astrólogo le asigna.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct Arquetipo {
72    /// Identificador estable — `"mars"`, `"virgo"`, `"conjunction"`…
73    pub nombre: String,
74    pub tipo: TipoArquetipo,
75    pub perfil: PerfilSemantico,
76}
77
78/// El plano vivencial donde una configuración descarga su energía. La
79/// contradicción «hiperdisciplinado vs. disperso» no se promedia: cada
80/// fuerza vive intacta en su dominio (general en la oficina, poeta
81/// disperso en la soledad).
82#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum Dominio {
85    /// Cuerpo, salud, acción directa (casas 1/5/9).
86    Vital,
87    /// Trabajo, vínculos, entorno (casas 3/7/11).
88    Social,
89    /// Inconsciente, miedos, indagación interna (casas 4/8/12).
90    Psiquico,
91}
92
93impl Dominio {
94    /// Dominio vivencial de una casa `1..=12`.
95    pub fn de_casa(casa: u8) -> Option<Dominio> {
96        match casa {
97            1 | 5 | 9 => Some(Dominio::Vital),
98            3 | 7 | 11 => Some(Dominio::Social),
99            4 | 8 | 12 => Some(Dominio::Psiquico),
100            2 | 6 | 10 => Some(Dominio::Social), // casas de recursos/trabajo
101            _ => None,
102        }
103    }
104}
105
106/// La «etiqueta de código de barras» de una combinación astrológica —
107/// la clave del JOIN. Respeta la gramática: cada variante es un tipo
108/// distinto de combinación, no una bolsa plana.
109///
110/// Se (de)serializa como una **cadena** legible (`mars·virgo`,
111/// `mars@c6`, `mars square saturn`) para que el corpus se escriba a
112/// mano sin pelear con la sintaxis de enums. El punto medio `·` admite
113/// el alias ASCII `/` (`mars/virgo`), más fácil de teclear.
114#[derive(Debug, Clone, PartialEq, Eq, Hash)]
115pub enum CombinacionId {
116    /// Un planeta en un signo — `mars·virgo`.
117    PlanetaSigno { planeta: String, signo: String },
118    /// Un planeta en una casa — `mars@c6`.
119    PlanetaCasa { planeta: String, casa: u8 },
120    /// Un aspecto entre dos planetas — `mars□saturn`. Los dos extremos
121    /// se guardan ORDENADOS, así `mars□saturn` y `saturn□mars` son la
122    /// misma clave.
123    Aspecto { a: String, kind: String, b: String },
124}
125
126impl CombinacionId {
127    pub fn planeta_signo(planeta: impl Into<String>, signo: impl Into<String>) -> Self {
128        CombinacionId::PlanetaSigno {
129            planeta: planeta.into(),
130            signo: signo.into(),
131        }
132    }
133
134    pub fn planeta_casa(planeta: impl Into<String>, casa: u8) -> Self {
135        CombinacionId::PlanetaCasa {
136            planeta: planeta.into(),
137            casa,
138        }
139    }
140
141    /// Construye un aspecto NORMALIZANDO el orden de los extremos, para
142    /// que la dirección no genere dos claves distintas.
143    pub fn aspecto(
144        a: impl Into<String>,
145        kind: impl Into<String>,
146        b: impl Into<String>,
147    ) -> Self {
148        let (a, b) = (a.into(), b.into());
149        let (a, b) = if a <= b { (a, b) } else { (b, a) };
150        CombinacionId::Aspecto {
151            a,
152            kind: kind.into(),
153            b,
154        }
155    }
156}
157
158impl std::fmt::Display for CombinacionId {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        match self {
161            CombinacionId::PlanetaSigno { planeta, signo } => {
162                write!(f, "{planeta}·{signo}")
163            }
164            CombinacionId::PlanetaCasa { planeta, casa } => write!(f, "{planeta}@c{casa}"),
165            CombinacionId::Aspecto { a, kind, b } => write!(f, "{a} {kind} {b}"),
166        }
167    }
168}
169
170impl FromStr for CombinacionId {
171    type Err = String;
172
173    /// Parsea el código de barras: `planeta·signo` (o `planeta/signo`),
174    /// `planeta@cN`, o `a kind b` (tres tokens separados por espacios).
175    fn from_str(s: &str) -> Result<Self, String> {
176        let s = s.trim();
177        if let Some((planeta, signo)) = s.split_once('·').or_else(|| s.split_once('/')) {
178            return Ok(CombinacionId::planeta_signo(planeta.trim(), signo.trim()));
179        }
180        if let Some((planeta, casa)) = s.split_once("@c") {
181            let casa: u8 = casa
182                .trim()
183                .parse()
184                .map_err(|_| format!("casa inválida en '{s}'"))?;
185            return Ok(CombinacionId::planeta_casa(planeta.trim(), casa));
186        }
187        let toks: Vec<&str> = s.split_whitespace().collect();
188        if toks.len() == 3 {
189            return Ok(CombinacionId::aspecto(toks[0], toks[1], toks[2]));
190        }
191        Err(format!("combinación no reconocida: '{s}'"))
192    }
193}
194
195impl Serialize for CombinacionId {
196    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
197        s.serialize_str(&self.to_string())
198    }
199}
200
201impl<'de> Deserialize<'de> for CombinacionId {
202    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
203        let s = String::deserialize(d)?;
204        s.parse().map_err(serde::de::Error::custom)
205    }
206}
207
208/// La posición de un planeta en una carta concreta: en qué signo y en
209/// qué casa cae. Es la materia prima desde la que se derivan las
210/// [`CombinacionId`] de la carta — el puente entre lo que el motor
211/// astronómico calcula y las claves del JOIN del corpus.
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213pub struct Colocacion {
214    pub planeta: String,
215    pub signo: String,
216    pub casa: u8,
217}
218
219/// Un aspecto medido en una carta: dos planetas y el ángulo que los une.
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221pub struct AspectoEnCarta {
222    pub a: String,
223    pub kind: String,
224    pub b: String,
225}
226
227/// Deriva TODAS las combinaciones de una carta: por cada planeta, su
228/// `planeta·signo` y su `planeta@cN`; por cada aspecto medido, su
229/// `a kind b`. El resultado es la lista que se le pasa a
230/// [`Corpus::interpretar`] para hacer el JOIN.
231pub fn combinaciones_de_carta(
232    colocaciones: &[Colocacion],
233    aspectos: &[AspectoEnCarta],
234) -> Vec<CombinacionId> {
235    let mut out = Vec::with_capacity(colocaciones.len() * 2 + aspectos.len());
236    for c in colocaciones {
237        out.push(CombinacionId::planeta_signo(&c.planeta, &c.signo));
238        out.push(CombinacionId::planeta_casa(&c.planeta, c.casa));
239    }
240    for a in aspectos {
241        out.push(CombinacionId::aspecto(&a.a, &a.kind, &a.b));
242    }
243    out
244}
245
246/// La **tomografía** de la carta: reparte cada combinación en el dominio
247/// —o dominios— vivencial donde descarga su energía.
248///
249/// La carta es UNA sola configuración; rebanarla por dominio no la
250/// promedia ni la mutila, la MIRA desde un plano —como ver un cuerpo en
251/// tajadas—. Las reglas del corte:
252///
253/// - un `planeta@cN` cae en el dominio de su casa;
254/// - un `planeta·signo` hereda el dominio de la casa donde ESE planeta
255///   está colocado en la carta;
256/// - un aspecto **puentea**: aparece en el dominio de cada uno de sus
257///   dos extremos. Que una misma combinación salga en dos rebanadas no
258///   es un error — es la conexión real entre dos planos.
259///
260/// Una combinación cuyo planeta no figura en `colocaciones` se omite (no
261/// hay forma de saber en qué dominio ubicarla).
262pub fn rebanar_por_dominio(
263    colocaciones: &[Colocacion],
264    combinaciones: &[CombinacionId],
265) -> BTreeMap<Dominio, Vec<CombinacionId>> {
266    let casa_de: BTreeMap<&str, u8> = colocaciones
267        .iter()
268        .map(|c| (c.planeta.as_str(), c.casa))
269        .collect();
270    let dominio_de = |planeta: &str| -> Option<Dominio> {
271        casa_de.get(planeta).copied().and_then(Dominio::de_casa)
272    };
273
274    let mut tajadas: BTreeMap<Dominio, Vec<CombinacionId>> = BTreeMap::new();
275    for id in combinaciones {
276        let dominios: Vec<Dominio> = match id {
277            CombinacionId::PlanetaCasa { casa, .. } => {
278                Dominio::de_casa(*casa).into_iter().collect()
279            }
280            CombinacionId::PlanetaSigno { planeta, .. } => {
281                dominio_de(planeta).into_iter().collect()
282            }
283            CombinacionId::Aspecto { a, b, .. } => {
284                let mut ds = Vec::new();
285                for p in [a.as_str(), b.as_str()] {
286                    if let Some(d) = dominio_de(p) {
287                        if !ds.contains(&d) {
288                            ds.push(d);
289                        }
290                    }
291                }
292                ds
293            }
294        };
295        for d in dominios {
296            tajadas.entry(d).or_default().push(id.clone());
297        }
298    }
299    tajadas
300}
301
302/// Un fragmento de interpretación: el texto de un autor (o del propio
303/// astrólogo) recortado y etiquetado con la combinación que describe.
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct Pasaje {
306    /// La combinación que este pasaje interpreta — la clave del JOIN.
307    pub combinacion: CombinacionId,
308    /// El texto, citado literalmente.
309    pub texto: String,
310    /// Procedencia — autor y obra, o `"propio"`. Convención: un pasaje
311    /// con fuente `"deducido"` es un perfil compuesto, no un texto de
312    /// libro (capa de composición, aún sin construir).
313    pub fuente: String,
314    /// Firma semántica del pasaje. Opcional: vacío hasta que se calcule.
315    #[serde(default)]
316    pub perfil: PerfilSemantico,
317    /// Dominio vivencial donde aplica, si el pasaje lo acota.
318    #[serde(default)]
319    pub dominio: Option<Dominio>,
320}
321
322/// El corpus completo: la ontología de arquetipos + los pasajes.
323#[derive(Debug, Clone, Default, Serialize, Deserialize)]
324pub struct Corpus {
325    pub arquetipos: Vec<Arquetipo>,
326    pub pasajes: Vec<Pasaje>,
327}
328
329impl Corpus {
330    /// Carga un corpus desde su forma RON (el formato de los archivos
331    /// que el astrólogo escribe a mano).
332    pub fn desde_ron(texto: &str) -> Result<Corpus, String> {
333        ron::from_str(texto).map_err(|e| format!("corpus :: RON inválido: {e}"))
334    }
335
336    /// Serializa el corpus a RON.
337    pub fn a_ron(&self) -> Result<String, String> {
338        ron::to_string(self).map_err(|e| format!("corpus :: no se pudo serializar: {e}"))
339    }
340
341    /// El arquetipo con ese nombre y tipo, si existe.
342    pub fn arquetipo(&self, nombre: &str, tipo: TipoArquetipo) -> Option<&Arquetipo> {
343        self.arquetipos
344            .iter()
345            .find(|a| a.nombre == nombre && a.tipo == tipo)
346    }
347
348    /// Todos los pasajes que interpretan una combinación dada.
349    pub fn pasajes_de(&self, id: &CombinacionId) -> Vec<&Pasaje> {
350        self.pasajes.iter().filter(|p| &p.combinacion == id).collect()
351    }
352
353    /// El JOIN: dada la lista de combinaciones de una carta, devuelve
354    /// todos los pasajes del corpus que las interpretan. Cobertura
355    /// total — no se salta una combinación que tenga texto. Combinar
356    /// estos pasajes en una narrativa coherente (síntesis) es trabajo
357    /// de una capa superior; aquí sólo se RECUPERA la evidencia.
358    pub fn interpretar(&self, combinaciones: &[CombinacionId]) -> Vec<&Pasaje> {
359        let mut out = Vec::new();
360        for id in combinaciones {
361            out.extend(self.pasajes_de(id));
362        }
363        out
364    }
365
366    /// El JOIN **rebanado por dominio**: para cada plano vivencial, los
367    /// pasajes que lo interpretan. Es la entrada directa de un gráfico
368    /// «por tajadas» — una rebanada, una vista del cuerpo de la carta.
369    /// Un aspecto que puentea dos dominios trae sus pasajes a las dos
370    /// rebanadas.
371    pub fn interpretar_por_dominio(
372        &self,
373        colocaciones: &[Colocacion],
374        aspectos: &[AspectoEnCarta],
375    ) -> BTreeMap<Dominio, Vec<&Pasaje>> {
376        let combinaciones = combinaciones_de_carta(colocaciones, aspectos);
377        rebanar_por_dominio(colocaciones, &combinaciones)
378            .into_iter()
379            .map(|(dominio, ids)| {
380                let mut pasajes = Vec::new();
381                for id in &ids {
382                    pasajes.extend(self.pasajes_de(id));
383                }
384                (dominio, pasajes)
385            })
386            .collect()
387    }
388
389    /// Combinaciones del corpus que NO tienen ni un solo pasaje — los
390    /// huecos que habría que escribir, o cubrir con composición.
391    pub fn huecos(&self, combinaciones: &[CombinacionId]) -> Vec<CombinacionId> {
392        combinaciones
393            .iter()
394            .filter(|id| self.pasajes_de(id).is_empty())
395            .cloned()
396            .collect()
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn aspecto_normaliza_el_orden_de_los_extremos() {
406        let ab = CombinacionId::aspecto("mars", "square", "saturn");
407        let ba = CombinacionId::aspecto("saturn", "square", "mars");
408        assert_eq!(ab, ba, "mars□saturn y saturn□mars son la misma clave");
409    }
410
411    #[test]
412    fn display_da_un_codigo_de_barras_legible() {
413        assert_eq!(
414            CombinacionId::planeta_signo("mars", "virgo").to_string(),
415            "mars·virgo"
416        );
417        assert_eq!(
418            CombinacionId::planeta_casa("mars", 6).to_string(),
419            "mars@c6"
420        );
421    }
422
423    fn pasaje(id: CombinacionId, texto: &str) -> Pasaje {
424        Pasaje {
425            combinacion: id,
426            texto: texto.into(),
427            fuente: "test".into(),
428            perfil: PerfilSemantico::new(),
429            dominio: None,
430        }
431    }
432
433    #[test]
434    fn interpretar_hace_el_join_de_las_combinaciones() {
435        let corpus = Corpus {
436            arquetipos: Vec::new(),
437            pasajes: vec![
438                pasaje(
439                    CombinacionId::planeta_signo("mars", "virgo"),
440                    "el guerrero cirujano",
441                ),
442                pasaje(
443                    CombinacionId::aspecto("mars", "square", "saturn"),
444                    "acción frenada",
445                ),
446                pasaje(
447                    CombinacionId::planeta_signo("moon", "pisces"),
448                    "sensibilidad difusa",
449                ),
450            ],
451        };
452        // Una carta con sólo dos de las tres combinaciones.
453        let carta = [
454            CombinacionId::planeta_signo("mars", "virgo"),
455            // El orden inverso debe resolver igual.
456            CombinacionId::aspecto("saturn", "square", "mars"),
457        ];
458        let recuperados = corpus.interpretar(&carta);
459        assert_eq!(recuperados.len(), 2);
460        assert!(recuperados.iter().any(|p| p.texto == "el guerrero cirujano"));
461        assert!(recuperados.iter().any(|p| p.texto == "acción frenada"));
462    }
463
464    #[test]
465    fn huecos_detecta_combinaciones_sin_pasaje() {
466        let corpus = Corpus {
467            arquetipos: Vec::new(),
468            pasajes: vec![pasaje(
469                CombinacionId::planeta_signo("mars", "virgo"),
470                "x",
471            )],
472        };
473        let carta = [
474            CombinacionId::planeta_signo("mars", "virgo"),
475            CombinacionId::planeta_signo("venus", "leo"),
476        ];
477        let huecos = corpus.huecos(&carta);
478        assert_eq!(huecos.len(), 1);
479        assert_eq!(huecos[0], CombinacionId::planeta_signo("venus", "leo"));
480    }
481
482    #[test]
483    fn corpus_roundtrip_ron() {
484        let corpus = Corpus {
485            arquetipos: vec![Arquetipo {
486                nombre: "mars".into(),
487                tipo: TipoArquetipo::Planeta,
488                perfil: BTreeMap::from([("accion".into(), 0.9_f32)]),
489            }],
490            pasajes: vec![pasaje(
491                CombinacionId::planeta_signo("mars", "virgo"),
492                "el guerrero cirujano",
493            )],
494        };
495        let ron = corpus.a_ron().expect("serializa");
496        let vuelta = Corpus::desde_ron(&ron).expect("deserializa");
497        assert_eq!(vuelta.arquetipos.len(), 1);
498        assert_eq!(vuelta.pasajes.len(), 1);
499        assert_eq!(vuelta.pasajes[0].texto, "el guerrero cirujano");
500    }
501
502    #[test]
503    fn dominio_de_casa_clasifica_los_planos() {
504        assert_eq!(Dominio::de_casa(1), Some(Dominio::Vital));
505        assert_eq!(Dominio::de_casa(7), Some(Dominio::Social));
506        assert_eq!(Dominio::de_casa(12), Some(Dominio::Psiquico));
507        assert_eq!(Dominio::de_casa(13), None);
508    }
509
510    #[test]
511    fn combinacion_id_roundtrip_string() {
512        for id in [
513            CombinacionId::planeta_signo("venus", "leo"),
514            CombinacionId::planeta_casa("sun", 10),
515            CombinacionId::aspecto("moon", "trine", "jupiter"),
516        ] {
517            let s = id.to_string();
518            let vuelta: CombinacionId = s.parse().expect("parsea su propio Display");
519            assert_eq!(vuelta, id);
520        }
521    }
522
523    #[test]
524    fn barra_es_alias_ascii_del_punto_medio() {
525        assert_eq!(
526            "mars/virgo".parse::<CombinacionId>().unwrap(),
527            CombinacionId::planeta_signo("mars", "virgo"),
528        );
529    }
530
531    /// Una carta mínima: Marte en Virgo en casa 6 (Social), Saturno en
532    /// Aries en casa 1 (Vital), y una cuadratura que los une.
533    fn carta_de_prueba() -> (Vec<Colocacion>, Vec<AspectoEnCarta>) {
534        let colocaciones = vec![
535            Colocacion {
536                planeta: "mars".into(),
537                signo: "virgo".into(),
538                casa: 6,
539            },
540            Colocacion {
541                planeta: "saturn".into(),
542                signo: "aries".into(),
543                casa: 1,
544            },
545        ];
546        let aspectos = vec![AspectoEnCarta {
547            a: "mars".into(),
548            kind: "square".into(),
549            b: "saturn".into(),
550        }];
551        (colocaciones, aspectos)
552    }
553
554    #[test]
555    fn combinaciones_de_carta_deriva_signo_casa_y_aspectos() {
556        let (colocaciones, aspectos) = carta_de_prueba();
557        let combos = combinaciones_de_carta(&colocaciones, &aspectos);
558        // 2 planetas × (signo + casa) + 1 aspecto.
559        assert_eq!(combos.len(), 5);
560        assert!(combos.contains(&CombinacionId::planeta_signo("mars", "virgo")));
561        assert!(combos.contains(&CombinacionId::planeta_casa("saturn", 1)));
562        assert!(combos.contains(&CombinacionId::aspecto("mars", "square", "saturn")));
563    }
564
565    #[test]
566    fn rebanar_por_dominio_reparte_y_el_aspecto_puentea() {
567        let (colocaciones, aspectos) = carta_de_prueba();
568        let combos = combinaciones_de_carta(&colocaciones, &aspectos);
569        let tajadas = rebanar_por_dominio(&colocaciones, &combos);
570
571        // Marte en casa 6 → Social ; Saturno en casa 1 → Vital.
572        let social = tajadas.get(&Dominio::Social).expect("hay tajada social");
573        let vital = tajadas.get(&Dominio::Vital).expect("hay tajada vital");
574        assert_eq!(social.len(), 3);
575        assert_eq!(vital.len(), 3);
576
577        // El aspecto cruza los dos planos: sale en las dos tajadas.
578        let aspecto = CombinacionId::aspecto("mars", "square", "saturn");
579        assert!(social.contains(&aspecto));
580        assert!(vital.contains(&aspecto));
581    }
582
583    #[test]
584    fn interpretar_por_dominio_agrupa_pasajes() {
585        let (colocaciones, aspectos) = carta_de_prueba();
586        let corpus = Corpus {
587            arquetipos: Vec::new(),
588            pasajes: vec![
589                pasaje(CombinacionId::planeta_casa("mars", 6), "trabajo intenso"),
590                pasaje(CombinacionId::planeta_casa("saturn", 1), "cuerpo severo"),
591            ],
592        };
593        let por_dominio = corpus.interpretar_por_dominio(&colocaciones, &aspectos);
594        assert_eq!(por_dominio[&Dominio::Social].len(), 1);
595        assert_eq!(por_dominio[&Dominio::Vital].len(), 1);
596        assert_eq!(por_dominio[&Dominio::Social][0].texto, "trabajo intenso");
597    }
598
599    #[test]
600    fn ejemplo_ron_carga() {
601        let corpus = Corpus::desde_ron(include_str!("../ejemplo.ron"))
602            .expect("ejemplo.ron debe ser RON válido");
603        assert!(!corpus.arquetipos.is_empty(), "la plantilla trae arquetipos");
604        assert!(!corpus.pasajes.is_empty(), "la plantilla trae pasajes");
605        // El pasaje del aspecto fija su dominio explícitamente.
606        let aspecto = CombinacionId::aspecto("mars", "square", "saturn");
607        let p = corpus.pasajes_de(&aspecto);
608        assert_eq!(p.len(), 1);
609        assert_eq!(p[0].dominio, Some(Dominio::Psiquico));
610    }
611}