feat(cosmobiologia-corpus): tomografía por dominio + plantilla y guía
El corpus ya rebana la carta en tajadas vivenciales: una sola
configuración mirada plano a plano, sin promediar la contradicción.
- Colocacion / AspectoEnCarta: la posición real de un planeta en una
carta — el puente entre el motor astronómico y las claves del JOIN.
- combinaciones_de_carta: deriva todas las CombinacionId de una carta.
- rebanar_por_dominio: la tomografía — cada planeta@cN cae en el
dominio de su casa, cada planeta·signo hereda el de su casa, y un
aspecto puentea apareciendo en las dos tajadas que conecta.
- Corpus::interpretar_por_dominio: el JOIN agrupado por dominio,
entrada directa del gráfico «por tajadas».
- CombinacionId acepta el alias ASCII '/' del punto medio '·'.
- ejemplo.ron: plantilla cargable y comentada del corpus.
- GUIA.md: los pasos exactos para generar el corpus a mano.
12 tests verdes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
diff --git a/Cargo.lock b/Cargo.lock
index 2cd2d81..ecd98df 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3047,6 +3047,14 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "cosmobiologia-corpus"
+version = "0.1.0"
+dependencies = [
+ "ron",
+ "serde",
+]
+
[[package]]
name = "cosmobiologia-engine"
version = "0.1.0"
diff --git a/crates/modules/cosmobiologia/cosmobiologia-corpus/GUIA.md b/crates/modules/cosmobiologia/cosmobiologia-corpus/GUIA.md
new file mode 100644
index 0000000..a8c14c7
--- /dev/null
+++ b/crates/modules/cosmobiologia/cosmobiologia-corpus/GUIA.md
@@ -0,0 +1,158 @@
+# Cómo generar el corpus de interpretación
+
+Esta guía dice **exactamente** qué hacer, a mano, para construir el
+corpus que `cosmobiologia` usará para interpretar cartas sin que ninguna
+IA invente una palabra.
+
+## Qué es (y qué NO es) el corpus
+
+El corpus **no es un set de reglas matemáticas**. No "calcula" la
+interpretación. Las reglas —qué planeta en qué signo, qué aspecto con
+qué orbe— las computa el motor astronómico. El corpus es la **biblioteca
+de evidencia**: fragmentos de texto —de los libros y de tu propia
+escritura— recortados y **etiquetados** con la combinación exacta que
+describen.
+
+En runtime, las combinaciones de una carta hacen un **JOIN** contra el
+corpus y traen los textos, citados y con fuente. La síntesis (tejerlos
+en un párrafo continuo) es una capa posterior; el corpus solo
+**almacena** y **recupera**.
+
+La contradicción no se promedia. Un Marte hiperdisciplinado en el
+trabajo y disperso en la soledad **no** se colapsa a "medio
+disciplinado": cada fuerza vive intacta en su **dominio** vivencial. Por
+eso el corpus rebana la carta en tajadas (`Vital`, `Social`, `Psiquico`)
+— como ver un cuerpo en cortes tomográficos.
+
+## El formato
+
+Un archivo `.ron`. Mira `ejemplo.ron` en esta misma carpeta: es una
+plantilla cargable y comentada. Tiene dos secciones, `arquetipos` y
+`pasajes`.
+
+### El "código de barras" de una combinación
+
+Cada pasaje se etiqueta con una clave-cadena:
+
+| Tipo | Sintaxis | Ejemplo |
+|---|---|---|
+| Planeta en signo | `planeta·signo` o `planeta/signo` | `mars·virgo`, `mars/virgo` |
+| Planeta en casa | `planeta@cN` | `mars@c6` |
+| Aspecto entre dos planetas | `a kind b` (tres palabras) | `mars square saturn` |
+
+Reglas de los identificadores:
+
+- minúscula, ASCII, **una sola palabra** (usa `_`: `north_node`);
+- usa siempre el **mismo** nombre — `mars`, no `Marte` aquí y `mars`
+ allá, o el JOIN no engancha;
+- en un aspecto el orden da igual: `mars square saturn` y
+ `saturn square mars` quedan como la misma clave.
+
+## Los pasos
+
+### Paso 1 — Crea tu archivo
+
+```sh
+cd crates/modules/cosmobiologia/cosmobiologia-corpus
+cp ejemplo.ron corpus.ron
+```
+
+Trabaja sobre `corpus.ron`. Borra los tres pasajes-plantilla cuando
+tengas los tuyos.
+
+### Paso 2 — (Opcional, recomendado) Escribe la ontología
+
+En la sección `arquetipos`, una entrada por cada planeta, signo, casa y
+aspecto que uses. Cada una lleva un `perfil`: un mapa de **dimensiones
+psicológicas** —las nombras tú— con un peso en `[-1.0, 1.0]`.
+
+```ron
+(
+ nombre: "mars",
+ tipo: planeta, // planeta | signo | casa | aspecto
+ perfil: {
+ "accion": 0.9,
+ "deseo": 0.7,
+ },
+),
+```
+
+Esto **no es obligatorio para el JOIN** (el JOIN solo usa `pasajes`),
+pero es la base para, más adelante, deducir el perfil de una combinación
+que no llegaste a escribir. Si recién empiezas, puedes dejar
+`arquetipos: []` y volver luego.
+
+### Paso 3 — Cosecha los pasajes
+
+Esta es la carne. Una entrada en `pasajes` por cada fragmento de
+interpretación:
+
+```ron
+(
+ combinacion: "mars·virgo",
+ texto: "Cita literal, corta, del libro — o tu propia redacción.",
+ fuente: "Autor, Título de la obra, p. 123",
+),
+```
+
+Dos formas de avanzar; elige una:
+
+- **Por fuente** — tomas un libro y lo vacías combinación por
+ combinación. Bueno para cubrir un autor entero de forma pareja.
+- **Por carta** — tomas la carta que estás leyendo *ahora*, listas sus
+ combinaciones y solo escribes esas. Bueno para tener algo útil ya, sin
+ esperar a "terminar" el corpus (que nunca termina).
+
+Recomendado: empieza **por carta**. El corpus crece con cada consulta
+real.
+
+### Paso 4 — Cuida la fuente y el derecho de autor
+
+- Cita **corto** y **textual**, y **atribuye siempre** (autor, obra,
+ página). Fragmentos breves con cita son uso legítimo.
+- No copies capítulos enteros. Si quieres volcar una idea larga,
+ **reescríbela con tus palabras** y pon `fuente: "propio"`.
+- Convención reservada: `fuente: "deducido"` queda para perfiles
+ compuestos por código a futuro, no para texto de libro.
+
+### Paso 5 — Acota el dominio cuando el texto lo pida
+
+Si un pasaje describe la combinación **solo en un plano** de la vida,
+márcalo:
+
+```ron
+(
+ combinacion: "mars square saturn",
+ texto: "...",
+ fuente: "...",
+ dominio: Some(psiquico), // vital | social | psiquico
+),
+```
+
+Sin `dominio`, el pasaje aplica al dominio que le toque por la posición
+del planeta en la carta. Con `dominio`, lo fuerzas. Úsalo poco: solo
+cuando el autor habla de un plano concreto.
+
+### Paso 6 — Valida el archivo
+
+```sh
+cargo test -p cosmobiologia-corpus
+```
+
+Si tu RON tiene un error de sintaxis, el test `ejemplo_ron_carga`
+te marca el formato correcto; para validar `corpus.ron` directamente,
+cárgalo desde un binario o un test propio con `Corpus::desde_ron`.
+
+### Paso 7 — Busca los huecos
+
+Con la carta cargada, `Corpus::huecos(&combinaciones)` devuelve las
+combinaciones de esa carta que **no tienen ni un pasaje**. Esa lista es,
+literalmente, tu cola de trabajo: lo que falta escribir.
+
+## Cuánto es "suficiente"
+
+El universo completo es grande (≈10 planetas × 12 signos = 120, otras
+120 planeta-en-casa, y los aspectos). No lo persigas. El 80 % del valor
+sale del 20 %: las combinaciones que de verdad aparecen en las cartas
+que lees. Empieza con una carta, deja que `huecos` te guíe, y el corpus
+se llena solo, consulta a consulta.
diff --git a/crates/modules/cosmobiologia/cosmobiologia-corpus/ejemplo.ron b/crates/modules/cosmobiologia/cosmobiologia-corpus/ejemplo.ron
new file mode 100644
index 0000000..266ffc5
--- /dev/null
+++ b/crates/modules/cosmobiologia/cosmobiologia-corpus/ejemplo.ron
@@ -0,0 +1,72 @@
+// ejemplo.ron — plantilla del corpus de interpretación.
+//
+// Cópialo a un archivo propio (p. ej. `corpus.ron`) y reemplaza el
+// contenido por el tuyo. Tiene dos secciones:
+//
+// arquetipos — la ontología: cada planeta / signo / casa / aspecto
+// con su perfil semántico (las dimensiones las nombras
+// TÚ; el código no presupone ninguna).
+// pasajes — la evidencia: texto real, etiquetado por combinación,
+// con su fuente. Es lo que el JOIN recupera.
+//
+// Sintaxis de la clave `combinacion` (el "código de barras"):
+// "mars·virgo" o "mars/virgo" — un planeta en un signo
+// "mars@c6" — un planeta en una casa
+// "mars square saturn" — un aspecto (TRES palabras)
+//
+// Identificadores: minúscula, ASCII, una sola palabra (usa "_" para
+// nombres compuestos, p. ej. "north_node"). Un aspecto se guarda con
+// sus extremos ordenados, así "mars square saturn" y
+// "saturn square mars" son la misma clave.
+//
+// Los campos `perfil` y `dominio` de un pasaje son opcionales: omítelos
+// hasta que los necesites.
+(
+ arquetipos: [
+ (
+ nombre: "mars",
+ tipo: planeta,
+ perfil: {
+ "accion": 0.9,
+ "deseo": 0.7,
+ "agresion": 0.5,
+ },
+ ),
+ (
+ nombre: "saturn",
+ tipo: planeta,
+ perfil: {
+ "estructura": 0.9,
+ "limite": 0.8,
+ "miedo": 0.5,
+ },
+ ),
+ (
+ nombre: "virgo",
+ tipo: signo,
+ perfil: {
+ "precision": 0.9,
+ "servicio": 0.6,
+ "ansiedad": 0.4,
+ },
+ ),
+ ],
+ pasajes: [
+ (
+ combinacion: "mars·virgo",
+ texto: "La energía marciana se vuelve cirujana: actúa con método, corrige, perfecciona. El impulso ya no arrasa, disecciona.",
+ fuente: "plantilla — reemplaza por tu cita y su autor",
+ ),
+ (
+ combinacion: "mars@c6",
+ texto: "El deseo se descarga en el trabajo cotidiano y en el cuidado del cuerpo. Riesgo de agotamiento por exceso de tarea.",
+ fuente: "plantilla — reemplaza por tu cita y su autor",
+ ),
+ (
+ combinacion: "mars square saturn",
+ texto: "Acción y freno tironean a la vez. La frustración, con los años, forja una voluntad templada.",
+ fuente: "plantilla — reemplaza por tu cita y su autor",
+ dominio: Some(psiquico),
+ ),
+ ],
+)
diff --git a/crates/modules/cosmobiologia/cosmobiologia-corpus/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-corpus/src/lib.rs
index ef6abdb..064c4f5 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-corpus/src/lib.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-corpus/src/lib.rs
@@ -28,15 +28,19 @@
//! un bloque tiene en 0 se queda en 0, no «se enciende»). Este crate
//! trae las capas 1-2 y deja la 3 sin resolver a propósito.
//!
-//! La síntesis narrativa y la separación por dominios vivenciales se
-//! resuelven en capas superiores; este crate sólo modela el almacén y
-//! el JOIN.
+//! La **rebanada por dominio** —ver el cuerpo de la carta en tajadas—
+//! sí vive aquí ([`rebanar_por_dominio`]): es geometría sobre las
+//! claves, no síntesis. La carta es una sola configuración; cortarla
+//! por dominio vivencial no la promedia, la MIRA desde un plano. Lo
+//! único que queda fuera es la síntesis narrativa —tejer los pasajes
+//! recuperados en un texto continuo—, trabajo de una capa superior.
#![forbid(unsafe_code)]
use std::collections::BTreeMap;
+use std::str::FromStr;
-use serde::{Deserialize, Serialize};
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// Perfil semántico: dimensiones psicológicas/vivenciales con un peso,
/// por convención en `[-1.0, 1.0]`. Los **nombres** de las dimensiones
@@ -75,7 +79,7 @@ pub struct Arquetipo {
/// contradicción «hiperdisciplinado vs. disperso» no se promedia: cada
/// fuerza vive intacta en su dominio (general en la oficina, poeta
/// disperso en la soledad).
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Dominio {
/// Cuerpo, salud, acción directa (casas 1/5/9).
@@ -102,7 +106,12 @@ impl Dominio {
/// La «etiqueta de código de barras» de una combinación astrológica —
/// la clave del JOIN. Respeta la gramática: cada variante es un tipo
/// distinto de combinación, no una bolsa plana.
-#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+///
+/// Se (de)serializa como una **cadena** legible (`mars·virgo`,
+/// `mars@c6`, `mars square saturn`) para que el corpus se escriba a
+/// mano sin pelear con la sintaxis de enums. El punto medio `·` admite
+/// el alias ASCII `/` (`mars/virgo`), más fácil de teclear.
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum CombinacionId {
/// Un planeta en un signo — `mars·virgo`.
PlanetaSigno { planeta: String, signo: String },
@@ -158,6 +167,138 @@ impl std::fmt::Display for CombinacionId {
}
}
+impl FromStr for CombinacionId {
+ type Err = String;
+
+ /// Parsea el código de barras: `planeta·signo` (o `planeta/signo`),
+ /// `planeta@cN`, o `a kind b` (tres tokens separados por espacios).
+ fn from_str(s: &str) -> Result<Self, String> {
+ let s = s.trim();
+ if let Some((planeta, signo)) = s.split_once('·').or_else(|| s.split_once('/')) {
+ return Ok(CombinacionId::planeta_signo(planeta.trim(), signo.trim()));
+ }
+ if let Some((planeta, casa)) = s.split_once("@c") {
+ let casa: u8 = casa
+ .trim()
+ .parse()
+ .map_err(|_| format!("casa inválida en '{s}'"))?;
+ return Ok(CombinacionId::planeta_casa(planeta.trim(), casa));
+ }
+ let toks: Vec<&str> = s.split_whitespace().collect();
+ if toks.len() == 3 {
+ return Ok(CombinacionId::aspecto(toks[0], toks[1], toks[2]));
+ }
+ Err(format!("combinación no reconocida: '{s}'"))
+ }
+}
+
+impl Serialize for CombinacionId {
+ fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
+ s.serialize_str(&self.to_string())
+ }
+}
+
+impl<'de> Deserialize<'de> for CombinacionId {
+ fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
+ let s = String::deserialize(d)?;
+ s.parse().map_err(serde::de::Error::custom)
+ }
+}
+
+/// La posición de un planeta en una carta concreta: en qué signo y en
+/// qué casa cae. Es la materia prima desde la que se derivan las
+/// [`CombinacionId`] de la carta — el puente entre lo que el motor
+/// astronómico calcula y las claves del JOIN del corpus.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Colocacion {
+ pub planeta: String,
+ pub signo: String,
+ pub casa: u8,
+}
+
+/// Un aspecto medido en una carta: dos planetas y el ángulo que los une.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AspectoEnCarta {
+ pub a: String,
+ pub kind: String,
+ pub b: String,
+}
+
+/// Deriva TODAS las combinaciones de una carta: por cada planeta, su
+/// `planeta·signo` y su `planeta@cN`; por cada aspecto medido, su
+/// `a kind b`. El resultado es la lista que se le pasa a
+/// [`Corpus::interpretar`] para hacer el JOIN.
+pub fn combinaciones_de_carta(
+ colocaciones: &[Colocacion],
+ aspectos: &[AspectoEnCarta],
+) -> Vec<CombinacionId> {
+ let mut out = Vec::with_capacity(colocaciones.len() * 2 + aspectos.len());
+ for c in colocaciones {
+ out.push(CombinacionId::planeta_signo(&c.planeta, &c.signo));
+ out.push(CombinacionId::planeta_casa(&c.planeta, c.casa));
+ }
+ for a in aspectos {
+ out.push(CombinacionId::aspecto(&a.a, &a.kind, &a.b));
+ }
+ out
+}
+
+/// La **tomografía** de la carta: reparte cada combinación en el dominio
+/// —o dominios— vivencial donde descarga su energía.
+///
+/// La carta es UNA sola configuración; rebanarla por dominio no la
+/// promedia ni la mutila, la MIRA desde un plano —como ver un cuerpo en
+/// tajadas—. Las reglas del corte:
+///
+/// - un `planeta@cN` cae en el dominio de su casa;
+/// - un `planeta·signo` hereda el dominio de la casa donde ESE planeta
+/// está colocado en la carta;
+/// - un aspecto **puentea**: aparece en el dominio de cada uno de sus
+/// dos extremos. Que una misma combinación salga en dos rebanadas no
+/// es un error — es la conexión real entre dos planos.
+///
+/// Una combinación cuyo planeta no figura en `colocaciones` se omite (no
+/// hay forma de saber en qué dominio ubicarla).
+pub fn rebanar_por_dominio(
+ colocaciones: &[Colocacion],
+ combinaciones: &[CombinacionId],
+) -> BTreeMap<Dominio, Vec<CombinacionId>> {
+ let casa_de: BTreeMap<&str, u8> = colocaciones
+ .iter()
+ .map(|c| (c.planeta.as_str(), c.casa))
+ .collect();
+ let dominio_de = |planeta: &str| -> Option<Dominio> {
+ casa_de.get(planeta).copied().and_then(Dominio::de_casa)
+ };
+
+ let mut tajadas: BTreeMap<Dominio, Vec<CombinacionId>> = BTreeMap::new();
+ for id in combinaciones {
+ let dominios: Vec<Dominio> = match id {
+ CombinacionId::PlanetaCasa { casa, .. } => {
+ Dominio::de_casa(*casa).into_iter().collect()
+ }
+ CombinacionId::PlanetaSigno { planeta, .. } => {
+ dominio_de(planeta).into_iter().collect()
+ }
+ CombinacionId::Aspecto { a, b, .. } => {
+ let mut ds = Vec::new();
+ for p in [a.as_str(), b.as_str()] {
+ if let Some(d) = dominio_de(p) {
+ if !ds.contains(&d) {
+ ds.push(d);
+ }
+ }
+ }
+ ds
+ }
+ };
+ for d in dominios {
+ tajadas.entry(d).or_default().push(id.clone());
+ }
+ }
+ tajadas
+}
+
/// Un fragmento de interpretación: el texto de un autor (o del propio
/// astrólogo) recortado y etiquetado con la combinación que describe.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -222,6 +363,29 @@ impl Corpus {
out
}
+ /// El JOIN **rebanado por dominio**: para cada plano vivencial, los
+ /// pasajes que lo interpretan. Es la entrada directa de un gráfico
+ /// «por tajadas» — una rebanada, una vista del cuerpo de la carta.
+ /// Un aspecto que puentea dos dominios trae sus pasajes a las dos
+ /// rebanadas.
+ pub fn interpretar_por_dominio(
+ &self,
+ colocaciones: &[Colocacion],
+ aspectos: &[AspectoEnCarta],
+ ) -> BTreeMap<Dominio, Vec<&Pasaje>> {
+ let combinaciones = combinaciones_de_carta(colocaciones, aspectos);
+ rebanar_por_dominio(colocaciones, &combinaciones)
+ .into_iter()
+ .map(|(dominio, ids)| {
+ let mut pasajes = Vec::new();
+ for id in &ids {
+ pasajes.extend(self.pasajes_de(id));
+ }
+ (dominio, pasajes)
+ })
+ .collect()
+ }
+
/// Combinaciones del corpus que NO tienen ni un solo pasaje — los
/// huecos que habría que escribir, o cubrir con composición.
pub fn huecos(&self, combinaciones: &[CombinacionId]) -> Vec<CombinacionId> {
@@ -342,4 +506,106 @@ mod tests {
assert_eq!(Dominio::de_casa(12), Some(Dominio::Psiquico));
assert_eq!(Dominio::de_casa(13), None);
}
+
+ #[test]
+ fn combinacion_id_roundtrip_string() {
+ for id in [
+ CombinacionId::planeta_signo("venus", "leo"),
+ CombinacionId::planeta_casa("sun", 10),
+ CombinacionId::aspecto("moon", "trine", "jupiter"),
+ ] {
+ let s = id.to_string();
+ let vuelta: CombinacionId = s.parse().expect("parsea su propio Display");
+ assert_eq!(vuelta, id);
+ }
+ }
+
+ #[test]
+ fn barra_es_alias_ascii_del_punto_medio() {
+ assert_eq!(
+ "mars/virgo".parse::<CombinacionId>().unwrap(),
+ CombinacionId::planeta_signo("mars", "virgo"),
+ );
+ }
+
+ /// Una carta mínima: Marte en Virgo en casa 6 (Social), Saturno en
+ /// Aries en casa 1 (Vital), y una cuadratura que los une.
+ fn carta_de_prueba() -> (Vec<Colocacion>, Vec<AspectoEnCarta>) {
+ let colocaciones = vec![
+ Colocacion {
+ planeta: "mars".into(),
+ signo: "virgo".into(),
+ casa: 6,
+ },
+ Colocacion {
+ planeta: "saturn".into(),
+ signo: "aries".into(),
+ casa: 1,
+ },
+ ];
+ let aspectos = vec![AspectoEnCarta {
+ a: "mars".into(),
+ kind: "square".into(),
+ b: "saturn".into(),
+ }];
+ (colocaciones, aspectos)
+ }
+
+ #[test]
+ fn combinaciones_de_carta_deriva_signo_casa_y_aspectos() {
+ let (colocaciones, aspectos) = carta_de_prueba();
+ let combos = combinaciones_de_carta(&colocaciones, &aspectos);
+ // 2 planetas × (signo + casa) + 1 aspecto.
+ assert_eq!(combos.len(), 5);
+ assert!(combos.contains(&CombinacionId::planeta_signo("mars", "virgo")));
+ assert!(combos.contains(&CombinacionId::planeta_casa("saturn", 1)));
+ assert!(combos.contains(&CombinacionId::aspecto("mars", "square", "saturn")));
+ }
+
+ #[test]
+ fn rebanar_por_dominio_reparte_y_el_aspecto_puentea() {
+ let (colocaciones, aspectos) = carta_de_prueba();
+ let combos = combinaciones_de_carta(&colocaciones, &aspectos);
+ let tajadas = rebanar_por_dominio(&colocaciones, &combos);
+
+ // Marte en casa 6 → Social ; Saturno en casa 1 → Vital.
+ let social = tajadas.get(&Dominio::Social).expect("hay tajada social");
+ let vital = tajadas.get(&Dominio::Vital).expect("hay tajada vital");
+ assert_eq!(social.len(), 3);
+ assert_eq!(vital.len(), 3);
+
+ // El aspecto cruza los dos planos: sale en las dos tajadas.
+ let aspecto = CombinacionId::aspecto("mars", "square", "saturn");
+ assert!(social.contains(&aspecto));
+ assert!(vital.contains(&aspecto));
+ }
+
+ #[test]
+ fn interpretar_por_dominio_agrupa_pasajes() {
+ let (colocaciones, aspectos) = carta_de_prueba();
+ let corpus = Corpus {
+ arquetipos: Vec::new(),
+ pasajes: vec![
+ pasaje(CombinacionId::planeta_casa("mars", 6), "trabajo intenso"),
+ pasaje(CombinacionId::planeta_casa("saturn", 1), "cuerpo severo"),
+ ],
+ };
+ let por_dominio = corpus.interpretar_por_dominio(&colocaciones, &aspectos);
+ assert_eq!(por_dominio[&Dominio::Social].len(), 1);
+ assert_eq!(por_dominio[&Dominio::Vital].len(), 1);
+ assert_eq!(por_dominio[&Dominio::Social][0].texto, "trabajo intenso");
+ }
+
+ #[test]
+ fn ejemplo_ron_carga() {
+ let corpus = Corpus::desde_ron(include_str!("../ejemplo.ron"))
+ .expect("ejemplo.ron debe ser RON válido");
+ assert!(!corpus.arquetipos.is_empty(), "la plantilla trae arquetipos");
+ assert!(!corpus.pasajes.is_empty(), "la plantilla trae pasajes");
+ // El pasaje del aspecto fija su dominio explícitamente.
+ let aspecto = CombinacionId::aspecto("mars", "square", "saturn");
+ let p = corpus.pasajes_de(&aspecto);
+ assert_eq!(p.len(), 1);
+ assert_eq!(p[0].dominio, Some(Dominio::Psiquico));
+ }
}
diff --git a/renaser/Cargo.toml b/renaser/Cargo.toml
index f014b24..50fbca4 100644
--- a/renaser/Cargo.toml
+++ b/renaser/Cargo.toml
@@ -15,7 +15,10 @@ resolver = "2"
members = ["boot"]
# El kernel (bare-metal) y las apps WASM (target wasm32) se compilan aparte,
# cada cual con su propio target; quedan fuera del espacio de trabajo.
-exclude = ["kernel", "apps"]
+# `formato` —el formato del grafo en disco— tambien se excluye: es un nucleo
+# `no_std` que enlaza el kernel bare-metal, asi que se compila como dependencia
+# de cada lado (kernel y boot) y no como miembro del workspace anfitrion.
+exclude = ["kernel", "apps", "formato"]
# -----------------------------------------------------------------------------
# Metadatos compartidos: cada miembro hereda esta identidad con `*.workspace`.
diff --git a/renaser/boot/Cargo.toml b/renaser/boot/Cargo.toml
index b8f6485..cafc65e 100644
--- a/renaser/boot/Cargo.toml
+++ b/renaser/boot/Cargo.toml
@@ -17,6 +17,12 @@ description = "renaser :: constructor de imagen de disco UEFI y lanzador de QEMU
# Constructor de la imagen de disco UEFI. Corre en el anfitrion, usa `std`.
bootloader.workspace = true
+# El formato del grafo de objetos en disco (Fase 7b). Es el MISMO nucleo
+# `no_std` que enlaza el kernel: gracias a el, `boot` siembra la imagen de
+# disco con el grafo ya poblado —objetos de bytecode y Manifiesto de Genesis—
+# hablando byte a byte el idioma que el kernel leera.
+formato = { path = "../formato" }
+
# Dependencia de ARTEFACTO (RFC 3028). Cargo compila el kernel para
# `x86_64-unknown-none` —en aislamiento total de arquitectura— y nos inyecta la
# ruta de su ELF en la variable de entorno `CARGO_BIN_FILE_KERNEL_kernel`,
diff --git a/renaser/formato/Cargo.toml b/renaser/formato/Cargo.toml
new file mode 100644
index 0000000..fddc3e8
--- /dev/null
+++ b/renaser/formato/Cargo.toml
@@ -0,0 +1,38 @@
+# =============================================================================
+# renaser :: formato — el formato del grafo de objetos en disco
+# -----------------------------------------------------------------------------
+# Nucleo `#![no_std]` COMPARTIDO: lo enlaza el kernel bare-metal (target
+# `x86_64-unknown-none`) y, por ser no_std, tambien lo compila sin friccion el
+# anfitrion `boot`. Es la unica verdad del formato del grafo —tipos,
+# (de)serializacion postcard, hash BLAKE3, trazado de registros—, de modo que
+# kernel y constructor de imagen hablen exactamente el mismo idioma de disco.
+#
+# Queda EXCLUIDO del espacio de trabajo (ver el Cargo.toml raiz), como el
+# kernel: lo consume un paquete bare-metal, asi que fija sus versiones de
+# forma explicita, sin herencia del workspace.
+# =============================================================================
+
+[package]
+name = "formato"
+version = "0.1.0"
+edition = "2021"
+license = "MPL-2.0"
+authors = ["JL Soltech <gerencia@jlsoltech.com>"]
+description = "renaser :: formato del grafo de objetos en disco — compartido kernel ↔ boot"
+
+[lib]
+bench = false
+doctest = false
+
+[dependencies]
+# `serde` da el rasgo de (de)serializacion; `postcard` lo materializa en un
+# formato binario compacto — el que viaja al disco. Ambos `no_std`, sobre `alloc`.
+serde = { version = "1", default-features = false, features = ["alloc", "derive"] }
+postcard = { version = "1", default-features = false, features = ["alloc"] }
+# `blake3`: la funcion hash que da identidad a cada objeto. Se fuerza la
+# implementacion ESCALAR pura (`pure` + los cuatro `no_*`): el target del kernel
+# corre sin SSE, y un camino SIMD por deteccion en tiempo de ejecucion
+# ejecutaria instrucciones que la CPU, sin `CR4.OSFXSR`, rechazaria con un #UD.
+blake3 = { version = "1", default-features = false, features = [
+ "pure", "no_sse2", "no_sse41", "no_avx2", "no_avx512",
+] }
diff --git a/renaser/formato/src/lib.rs b/renaser/formato/src/lib.rs
new file mode 100644
index 0000000..7449b42
--- /dev/null
+++ b/renaser/formato/src/lib.rs
@@ -0,0 +1,286 @@
+// =============================================================================
+// renaser :: formato — el formato del grafo de objetos en disco
+// -----------------------------------------------------------------------------
+// Hasta la Fase 7a, el formato del grafo de objetos —el superbloque, los
+// registros del log, el manifiesto— vivia disperso entre `kernel/almacen.rs`
+// y `kernel/manifiesto.rs`. Lo conocia solo el kernel.
+//
+// La Fase 7b se lo entrega tambien a `boot`: el constructor de imagen de
+// ANFITRION debe sembrar el disco con el grafo ya poblado —los objetos de
+// bytecode y el Manifiesto de Genesis— para que el kernel jamas vuelva a
+// empotrar una sola app. Para ello, kernel y boot han de hablar EXACTAMENTE
+// el mismo formato: la misma serializacion, el mismo hash, el mismo trazado
+// de registros en el log.
+//
+// Esta crate es esa unica verdad. Es un nucleo `#![no_std]` —el kernel
+// bare-metal la enlaza— y, por ser no_std, el anfitrion `boot` la compila sin
+// friccion. Define los tipos del grafo, su (de)serializacion `postcard`, la
+// funcion hash BLAKE3 que da identidad a cada objeto y el trazado de un
+// registro en el log. Ni kernel ni boot vuelven a definir nada de esto.
+// =============================================================================
+
+#![no_std]
+
+extern crate alloc;
+
+use alloc::string::String;
+use alloc::vec;
+use alloc::vec::Vec;
+
+use serde::{Deserialize, Serialize};
+
+// =============================================================================
+// Constantes del formato en disco
+// =============================================================================
+
+/// Firma magica del superbloque — «RENASer GRaFo». Distingue un disco de
+/// renaser de uno virgen o ajeno.
+pub const MAGIA: [u8; 8] = *b"RENASGRF";
+
+/// Version del formato del superbloque en disco. Un disco con otra version se
+/// reformatea al arrancar. v2 (Fase 7) — el superbloque porta el ancla
+/// `manifiesto`, gemela de `raiz`.
+pub const VERSION_SUPERBLOQUE: u32 = 2;
+
+/// Version del formato del manifiesto serializado. Independiente de la del
+/// superbloque: el manifiesto es un objeto del grafo, no una estructura fija
+/// del disco.
+pub const VERSION_MANIFIESTO: u32 = 1;
+
+/// Techo del tamaño de un objeto serializado: 1 MiB. Acota los buferes de E/S
+/// y permite descartar un registro corrupto sin leer un disparate.
+pub const MAX_OBJETO: usize = 1024 * 1024;
+
+/// Tamaño de un sector del disco, en bytes. El log se traza en multiplos de
+/// esta unidad — la misma que expone el transporte virtio-blk.
+pub const TAM_SECTOR: usize = 512;
+
+/// El identificador de un objeto: el hash BLAKE3 de su forma serializada. En
+/// un almacen direccionado por contenido, la identidad ES el contenido.
+pub type Hash = [u8; 32];
+
+// =============================================================================
+// Los tipos del grafo
+// =============================================================================
+
+/// Un objeto del grafo: una carga util opaca y las aristas que lo enlazan con
+/// otros objetos. Los `hijos` hacen del almacen un DAG —no un arbol—: un
+/// objeto puede ser hijo de muchos, y el direccionamiento por contenido
+/// garantiza que cada contenido distinto se guarda una sola vez.
+#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
+pub struct Objeto {
+ /// La carga util del objeto: bytes crudos, que nadie interpreta aqui.
+ pub datos: Vec<u8>,
+ /// Los hashes de los objetos hijos: las aristas salientes del DAG.
+ pub hijos: Vec<Hash>,
+}
+
+/// El superbloque: el sector 0 del disco. Ancla el grafo entero — dice por
+/// donde continua el log, cual es el objeto raiz y cual el manifiesto.
+#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
+pub struct SuperBloque {
+ /// Firma magica: debe ser [`MAGIA`].
+ pub magia: [u8; 8],
+ /// Version del formato: debe ser [`VERSION_SUPERBLOQUE`].
+ pub version: u32,
+ /// Proximo sector libre del log — donde se anexara el siguiente objeto.
+ pub cursor: u64,
+ /// El objeto raiz del DAG: el punto de entrada que el userspace fija y lee.
+ pub raiz: Option<Hash>,
+ /// El Manifiesto de Genesis: el objeto que dicta que apps nacen del grafo
+ /// al arrancar. Ancla del kernel, gemela de `raiz` (del userspace).
+ pub manifiesto: Option<Hash>,
+}
+
+/// El Manifiesto de Genesis: la lista de aplicaciones que el kernel instancia
+/// al arrancar. Vive como un objeto del grafo; el superbloque guarda su hash.
+#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
+pub struct Manifiesto {
+ /// Version del formato — debe ser [`VERSION_MANIFIESTO`].
+ pub version: u32,
+ /// Las aplicaciones del userspace, en orden de arranque.
+ pub apps: Vec<EntradaApp>,
+}
+
+/// Una entrada del manifiesto: una aplicacion del userspace y todo lo que el
+/// kernel necesita para darle vida — su bytecode, su ventana, su cuota de
+/// memoria y, si lo tuviera, su ultimo estado persistido.
+#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
+pub struct EntradaApp {
+ /// Nombre legible — para los rotulos de la consola y la baliza.
+ pub nombre: String,
+ /// Hash del objeto del grafo que contiene el bytecode WASM de la app.
+ pub bytecode: Hash,
+ /// Sub-region del framebuffer asignada a la app. Campos de ancho fijo
+ /// `u32` A PROPOSITO: esto es un formato EN DISCO. La `RegionPantalla` del
+ /// kernel usa `usize` (ancho dependiente de plataforma) y no serializa.
+ pub region_x: u32,
+ pub region_y: u32,
+ pub region_ancho: u32,
+ pub region_alto: u32,
+ /// Techo de memoria lineal de la app, en bytes. Cada app lleva su cuota.
+ pub techo_memoria: u32,
+ /// Hash del ultimo estado persistido de la app (Fase 7c). `None` hasta que
+ /// la app guarde estado por primera vez.
+ pub estado: Option<Hash>,
+}
+
+// =============================================================================
+// (De)serializacion — la forma binaria que viaja al disco
+// =============================================================================
+
+impl Objeto {
+ /// Serializa el objeto a su forma binaria `postcard`.
+ pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
+ postcard::to_allocvec(self).map_err(|_| "objeto :: serializacion fallida")
+ }
+
+ /// Reconstruye un objeto desde su forma binaria. Tolera bytes sobrantes
+ /// tras el objeto —el relleno del registro—: solo consume su prefijo.
+ pub fn deserializar(bytes: &[u8]) -> Result<Objeto, &'static str> {
+ postcard::take_from_bytes::<Objeto>(bytes)
+ .map(|(objeto, _)| objeto)
+ .map_err(|_| "objeto :: deserializacion fallida")
+ }
+}
+
+impl SuperBloque {
+ /// Serializa el superbloque a su forma binaria `postcard`.
+ pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
+ postcard::to_allocvec(self).map_err(|_| "superbloque :: serializacion fallida")
+ }
+
+ /// Reconstruye el superbloque desde el sector 0. Tolera el relleno a cero
+ /// que completa el sector: solo consume el prefijo serializado.
+ pub fn deserializar(bytes: &[u8]) -> Result<SuperBloque, &'static str> {
+ postcard::take_from_bytes::<SuperBloque>(bytes)
+ .map(|(sb, _)| sb)
+ .map_err(|_| "superbloque :: deserializacion fallida")
+ }
+}
+
+impl Manifiesto {
+ /// Serializa el manifiesto a su forma binaria `postcard` — la carga util
+ /// del objeto del grafo que lo aloja.
+ pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
+ postcard::to_allocvec(self).map_err(|_| "manifiesto :: serializacion fallida")
+ }
+
+ /// Reconstruye un manifiesto desde la carga util de su objeto. Rechaza un
+ /// formato de version desconocida en lugar de malinterpretarlo.
+ pub fn deserializar(bytes: &[u8]) -> Result<Manifiesto, &'static str> {
+ let (manifiesto, _) = postcard::take_from_bytes::<Manifiesto>(bytes)
+ .map_err(|_| "manifiesto :: deserializacion fallida")?;
+ if manifiesto.version != VERSION_MANIFIESTO {
+ return Err("manifiesto :: version de formato desconocida");
+ }
+ Ok(manifiesto)
+ }
+}
+
+// =============================================================================
+// El hash y el trazado de un registro en el log
+// =============================================================================
+
+/// La identidad de un objeto: el hash BLAKE3 de su forma serializada. Kernel y
+/// `boot` la calculan por aqui — una sola definicion del hash, jamas dos.
+pub fn hash(bytes: &[u8]) -> Hash {
+ *blake3::hash(bytes).as_bytes()
+}
+
+/// Numero de sectores que ocupa un registro cuyo payload mide `longitud`
+/// bytes. Cada registro es `[longitud: u32 LE][payload postcard][relleno 0]`.
+pub fn sectores_registro(longitud: usize) -> u64 {
+ (4 + longitud).div_ceil(TAM_SECTOR) as u64
+}
+
+/// Compone el registro en disco de un payload: `[longitud u32 LE][payload]
+/// [relleno a cero]`, alineado a un numero entero de sectores. Es el trazado
+/// exacto que el kernel lee al reconstruir su indice — lo escriben tanto
+/// `kernel::almacen` (al anexar un objeto) como `boot` (al sembrar la imagen).
+pub fn componer_registro(payload: &[u8]) -> Vec<u8> {
+ let n = sectores_registro(payload.len()) as usize;
+ let mut registro = vec![0u8; n * TAM_SECTOR];
+ registro[0..4].copy_from_slice(&(payload.len() as u32).to_le_bytes());
+ registro[4..4 + payload.len()].copy_from_slice(payload);
+ registro
+}
+
+/// Lee la cabecera de longitud de un registro (sus 4 primeros bytes). Devuelve
+/// `None` si la longitud es cero —fin del log— o supera [`MAX_OBJETO`]
+/// —corrupcion—. Gemela de [`componer_registro`].
+pub fn longitud_registro(cabecera: &[u8]) -> Option<usize> {
+ if cabecera.len() < 4 {
+ return None;
+ }
+ let longitud =
+ u32::from_le_bytes([cabecera[0], cabecera[1], cabecera[2], cabecera[3]]) as usize;
+ if longitud == 0 || longitud > MAX_OBJETO {
+ None
+ } else {
+ Some(longitud)
+ }
+}
+
+// =============================================================================
+// Pruebas — el formato debe ser un espejo perfecto: lo escrito se relee igual
+// =============================================================================
+
+#[cfg(test)]
+mod pruebas {
+ use super::*;
+
+ #[test]
+ fn objeto_ida_y_vuelta() {
+ let objeto = Objeto {
+ datos: vec![1, 2, 3, 4, 5],
+ hijos: vec![[7u8; 32], [9u8; 32]],
+ };
+ let bytes = objeto.serializar().unwrap();
+ assert_eq!(Objeto::deserializar(&bytes).unwrap(), objeto);
+ }
+
+ #[test]
+ fn registro_alineado_a_sector() {
+ let payload = vec![0xABu8; 600];
+ let registro = componer_registro(&payload);
+ // 4 + 600 = 604 bytes => dos sectores de 512.
+ assert_eq!(registro.len(), 2 * TAM_SECTOR);
+ assert_eq!(registro.len() % TAM_SECTOR, 0);
+ assert_eq!(longitud_registro(®istro), Some(600));
+ assert_eq!(®istro[4..604], &payload[..]);
+ }
+
+ #[test]
+ fn cabecera_a_cero_es_fin_del_log() {
+ assert_eq!(longitud_registro(&[0, 0, 0, 0]), None);
+ assert_eq!(longitud_registro(&[0xFF, 0xFF, 0xFF, 0xFF]), None);
+ assert_eq!(longitud_registro(&[3, 0, 0, 0]), Some(3));
+ }
+
+ #[test]
+ fn manifiesto_rechaza_version_ajena() {
+ let mut manifiesto = Manifiesto {
+ version: 99,
+ apps: Vec::new(),
+ };
+ let bytes = postcard::to_allocvec(&manifiesto).unwrap();
+ assert!(Manifiesto::deserializar(&bytes).is_err());
+ manifiesto.version = VERSION_MANIFIESTO;
+ assert!(Manifiesto::deserializar(&manifiesto.serializar().unwrap()).is_ok());
+ }
+
+ #[test]
+ fn superbloque_cabe_en_un_sector_y_vuelve_intacto() {
+ let sb = SuperBloque {
+ magia: MAGIA,
+ version: VERSION_SUPERBLOQUE,
+ cursor: 4096,
+ raiz: Some([1u8; 32]),
+ manifiesto: Some([2u8; 32]),
+ };
+ let bytes = sb.serializar().unwrap();
+ assert!(bytes.len() <= TAM_SECTOR);
+ assert_eq!(SuperBloque::deserializar(&bytes).unwrap(), sb);
+ }
+}
diff --git a/renaser/kernel/Cargo.toml b/renaser/kernel/Cargo.toml
index 3ea7bda..658cf1e 100644
--- a/renaser/kernel/Cargo.toml
+++ b/renaser/kernel/Cargo.toml
@@ -45,19 +45,14 @@ wasmi = { version = "1.0", default-features = false, features = ["hash-collectio
# `virtio-drivers` bare-metal: el kernel implementa su `trait Hal` para el DMA.
virtio-drivers = { version = "0.13", default-features = false, features = ["alloc"] }
-# --- Fase 6.1c :: el grafo de objetos direccionado por contenido ---
-# `serde` da el rasgo de (de)serializacion; `postcard` lo materializa en un
-# formato binario compacto, pensado para sistemas empotrados — el que viaja al
-# disco. Ambos `no_std`, apoyados en `alloc`.
-serde = { version = "1", default-features = false, features = ["alloc", "derive"] }
-postcard = { version = "1", default-features = false, features = ["alloc"] }
-# `blake3`: la funcion hash que da identidad a cada objeto. Se fuerza la
-# implementacion ESCALAR pura (`pure` + los cuatro `no_*`): el target del kernel
-# corre sin SSE, y un camino SIMD activado por deteccion en tiempo de ejecucion
-# ejecutaria instrucciones que la CPU, sin `CR4.OSFXSR`, rechazaria con un #UD.
-blake3 = { version = "1", default-features = false, features = [
- "pure", "no_sse2", "no_sse41", "no_avx2", "no_avx512",
-] }
+# --- Fase 6.1c / 7b :: el grafo de objetos direccionado por contenido ---
+# El formato del grafo —tipos, (de)serializacion postcard, hash BLAKE3, trazado
+# de registros del log— vive en la crate `formato`, un nucleo `no_std`
+# COMPARTIDO con `boot` (que lo usa para sembrar la imagen de disco). El kernel
+# ya no declara `serde`/`postcard`/`blake3` por su cuenta: los hereda —con las
+# mismas features, BLAKE3 escalar puro incluido— a traves de `formato`. Una
+# sola verdad del formato de disco, imposible de divergir entre los dos lados.
+formato = { path = "../formato" }
# --- Fase 8 (preparación) :: el compositor ---
# `mirada-layout` es el motor de teselado del compositor de brahman —