gioser

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Sutichaykuna

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>

43e6b32e15dea99bd57a9a11fdafa7bd9a2a236f · ruwaq sergio · (5 killa ñawpaq) · tayta 121aa130a · kay commitpi sach'ata qhaway

Semántico sayk'uy · 42

renaser/formato/src/lib.rs

9 qillqakuna · +852 −20

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(&registro), Some(600));
+        assert_eq!(&registro[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 —