gioser

tawasuyu/brahman

Rust 496TOML 195Markdown 55JSON 197001 Definitions

feat(yachay): notebooks reproducibles — yachay-core + demo

yachay-core: notebook como secuencia de celdas (orden de lectura) +
DAG de dependencias (orden de ejecución). Celdas markdown/código/embed
con content_hash BLAKE3; editar una propaga staleness a descendientes;
digest Merkle por celda (content_hash ‖ digests upstream) y
notebook_digest que certifica reproducibilidad. Demo CLI en apps/yachay.

14 tests. Sin kernel ni UI, #![forbid(unsafe_code)].

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

e3980d005f2096694d54da0f5f3fc76fac8ae914 · by sergio · (5 months ago) · parent 3f8a3ea4b · browse the tree at this commit

Semantic delta · 48

crates/apps/yachay/src/main.rs

crates/modules/yachay/yachay-core/src/cell.rs

crates/modules/yachay/yachay-core/src/lib.rs

9 files · +661 −0

diff --git a/Cargo.lock b/Cargo.lock
index f535b12..6c975e3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -14875,6 +14875,21 @@ version = "0.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
 
+[[package]]
+name = "yachay"
+version = "0.1.0"
+dependencies = [
+ "yachay-core",
+]
+
+[[package]]
+name = "yachay-core"
+version = "0.1.0"
+dependencies = [
+ "blake3",
+ "serde",
+]
+
 [[package]]
 name = "yamux"
 version = "0.12.1"
diff --git a/Cargo.toml b/Cargo.toml
index a991cdc..70a0aba 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -143,6 +143,11 @@ members = [
     "crates/modules/matilda/matilda-config",
     "crates/modules/matilda/matilda-plan",
 
+    # ============================================================
+    # modules/yachay/ — Notebooks computacionales reproducibles
+    # ============================================================
+    "crates/modules/yachay/yachay-core",
+
     # ============================================================
     # modules/nakui/ — ERP matemático (categórico)
     # ============================================================
@@ -256,6 +261,7 @@ members = [
     "crates/apps/agorapura",
     "crates/apps/badu",
     "crates/apps/matilda",
+    "crates/apps/yachay",
 ]
 
 [workspace.package]
diff --git a/crates/apps/yachay/Cargo.toml b/crates/apps/yachay/Cargo.toml
new file mode 100644
index 0000000..c391a80
--- /dev/null
+++ b/crates/apps/yachay/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "yachay"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "yachay — demostración de notebooks reproducibles: DAG de celdas, orden de ejecución, propagación de staleness y digest Merkle."
+
+[[bin]]
+name = "yachay"
+path = "src/main.rs"
+
+[dependencies]
+yachay-core = { path = "../../modules/yachay/yachay-core" }
diff --git a/crates/apps/yachay/src/main.rs b/crates/apps/yachay/src/main.rs
new file mode 100644
index 0000000..1ae3498
--- /dev/null
+++ b/crates/apps/yachay/src/main.rs
@@ -0,0 +1,91 @@
+//! `yachay` — demostración de notebooks reproducibles.
+//!
+//! Arma un notebook con prosa, código y un embed de otro módulo
+//! brahman; imprime el orden de ejecución y el digest Merkle; luego
+//! edita una celda intermedia y muestra cómo la obsolescencia y el
+//! digest se propagan. `cargo run -p yachay`.
+
+use yachay_core::{CellId, CellKind, CellState, Notebook};
+
+/// Etiqueta de la clase de una celda.
+fn label(nb: &Notebook, id: CellId) -> &'static str {
+    match nb.cell(id).map(|c| &c.kind) {
+        Some(CellKind::Markdown) => "markdown",
+        Some(CellKind::Code { .. }) => "código  ",
+        Some(CellKind::Embed { .. }) => "embed   ",
+        None => "?       ",
+    }
+}
+
+/// Primeros bytes de un digest, en hex — suficiente para distinguirlos.
+fn short(digest: Option<[u8; 32]>) -> String {
+    match digest {
+        Some(d) => d[..6].iter().map(|b| format!("{b:02x}")).collect(),
+        None => "—(ciclo)".to_string(),
+    }
+}
+
+fn main() {
+    let mut nb = Notebook::new();
+
+    let intro = nb.push(CellKind::Markdown, "# Cosecha de auyama\nAnálisis del rendimiento.");
+    let datos = nb.push(
+        CellKind::Code { language: "rust".into() },
+        "let kilos = vec![12.0, 18.0, 9.5, 21.0];",
+    );
+    let media = nb.push(
+        CellKind::Code { language: "rust".into() },
+        "let media = kilos.iter().sum::<f64>() / kilos.len() as f64;",
+    );
+    let grafico = nb.push(
+        CellKind::Embed { module: "pineal".into() },
+        "barras: kilos por semana",
+    );
+
+    // DAG: media depende de datos; el gráfico depende de ambos.
+    nb.add_dependency(media, datos);
+    nb.add_dependency(grafico, datos);
+    nb.add_dependency(grafico, media);
+
+    println!("\n  yachay · notebook reproducible — {} celdas\n", nb.len());
+
+    println!("  orden de ejecución (según el DAG de dependencias):");
+    if let Some(order) = nb.execution_order() {
+        for (step, id) in order.iter().enumerate() {
+            println!(
+                "    {}. [{}] celda {}   digest {}",
+                step + 1,
+                label(&nb, *id),
+                id,
+                short(nb.digest(*id))
+            );
+        }
+    }
+
+    let digest_inicial = nb.notebook_digest();
+    println!("\n  digest del notebook: {}", short(digest_inicial));
+
+    // Marca todo Fresh y luego edita la celda de datos.
+    for c in nb.cells().iter().map(|c| c.id).collect::<Vec<_>>() {
+        nb.set_state(c, CellState::Fresh);
+    }
+    println!("\n  ── se edita la celda «datos» ──────────────────────────");
+    nb.set_source(datos, "let kilos = vec![12.0, 18.0, 9.5, 21.0, 30.0];");
+
+    println!("\n  estado de las celdas tras la edición:");
+    for id in [intro, datos, media, grafico] {
+        let st = match nb.cell(id).unwrap().state {
+            CellState::Fresh => "fresca",
+            CellState::Stale => "OBSOLETA",
+            CellState::Failed => "fallida",
+        };
+        println!("    [{}] celda {}  → {}", label(&nb, id), id, st);
+    }
+
+    println!("\n  digest del notebook: {}", short(nb.notebook_digest()));
+    println!(
+        "  {} la edición cambió el digest — la corrida anterior ya no\n  \
+         es reproducible bit a bit; hay que re-ejecutar lo obsoleto.\n",
+        if digest_inicial != nb.notebook_digest() { "✔" } else { "✘" }
+    );
+}
diff --git a/crates/modules/yachay/SDD.md b/crates/modules/yachay/SDD.md
new file mode 100644
index 0000000..b8514e8
--- /dev/null
+++ b/crates/modules/yachay/SDD.md
@@ -0,0 +1,46 @@
+# modules/yachay/ — Notebooks computacionales reproducibles
+
+**Propósito.** Notebooks donde la reproducibilidad es verificable, no
+prometida. Un notebook es a la vez una secuencia de celdas (orden de
+lectura) y un DAG de dependencias (orden de ejecución). Un digest Merkle
+certifica que dos corridas del mismo notebook producen lo mismo.
+
+## Crates
+
+| crate         | tipo | rol                                                          |
+| ------------- | ---- | ------------------------------------------------------------ |
+| `yachay-core` | lib  | `Cell` (markdown/código/embed), `Notebook` (DAG, staleness, digest) |
+
+App: `apps/yachay` — demo CLI (`cargo run -p yachay`).
+
+## Modelo
+
+```text
+  Cell { kind, source, depends_on } ──► Notebook
+        │                                  │
+   content_hash (BLAKE3)            execution_order (topológico)
+        │                                  │
+        └──► digest = BLAKE3(content_hash ‖ digests upstream) ──► notebook_digest
+```
+
+- **Doble estructura**: orden de presentación (lista) + DAG de
+  dependencias. La ejecución sigue el DAG.
+- **Staleness**: editar una celda la marca `Stale` y propaga la
+  obsolescencia a sus descendientes (no a sus ancestros).
+- **Digest Merkle**: el digest de una celda cubre su contenido y todo su
+  linaje; dos notebooks con el mismo `notebook_digest` son
+  reproduciblemente equivalentes. El orden de declaración de
+  dependencias no lo afecta.
+- **Embeds**: una celda puede incrustar la visualización de otro módulo
+  brahman (`dominium`, `pineal`, `takiy`) — yachay integra el ecosistema.
+
+## Dependencias
+
+- `yachay-core` ← `blake3` + `serde`. `#![forbid(unsafe_code)]`.
+- Sin kernel, sin ejecución real, sin UI — tipos puros y deterministas.
+
+## Estado
+
+`yachay-core` implementado y verde (14 tests) + demo CLI. **Pendiente**:
+los kernels de ejecución de código, el render de los embeds (consume los
+`*-render-plan` de cada módulo), persistencia y el frontend GPUI.
diff --git a/crates/modules/yachay/yachay-core/Cargo.toml b/crates/modules/yachay/yachay-core/Cargo.toml
new file mode 100644
index 0000000..22af006
--- /dev/null
+++ b/crates/modules/yachay/yachay-core/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "yachay-core"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "yachay — núcleo de notebooks reproducibles: celdas en DAG, propagación de staleness y digest Merkle que certifica reproducibilidad."
+
+[dependencies]
+blake3 = { workspace = true }
+serde = { workspace = true }
diff --git a/crates/modules/yachay/yachay-core/src/cell.rs b/crates/modules/yachay/yachay-core/src/cell.rs
new file mode 100644
index 0000000..dce5f07
--- /dev/null
+++ b/crates/modules/yachay/yachay-core/src/cell.rs
@@ -0,0 +1,104 @@
+//! La celda — la unidad de un notebook.
+
+use serde::{Deserialize, Serialize};
+
+/// Identificador de una celda dentro de su notebook.
+pub type CellId = u64;
+
+/// Qué clase de contenido lleva una celda.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum CellKind {
+    /// Prosa en markdown.
+    Markdown,
+    /// Código ejecutable en un lenguaje.
+    Code { language: String },
+    /// Una visualización de un módulo brahman (`"dominium"`, `"pineal"`,
+    /// `"takiy"`) — yachay integra el ecosistema.
+    Embed { module: String },
+}
+
+impl CellKind {
+    /// Etiqueta estable que distingue las clases en el hash de contenido.
+    fn tag(&self) -> &'static str {
+        match self {
+            CellKind::Markdown => "md",
+            CellKind::Code { .. } => "code",
+            CellKind::Embed { .. } => "embed",
+        }
+    }
+}
+
+/// Estado de frescura de una celda respecto de sus dependencias.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum CellState {
+    /// Al día: su resultado corresponde a las fuentes actuales.
+    Fresh,
+    /// Obsoleta: ella o una dependencia cambió y falta re-ejecutar.
+    Stale,
+    /// Su última ejecución falló.
+    Failed,
+}
+
+/// Una celda del notebook: contenido + sus dependencias lógicas.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Cell {
+    pub id: CellId,
+    pub kind: CellKind,
+    /// El texto fuente — markdown, código o el spec del embed.
+    pub source: String,
+    /// Celdas prerrequisito (deben ejecutarse antes).
+    pub depends_on: Vec<CellId>,
+    pub state: CellState,
+}
+
+impl Cell {
+    /// Hash BLAKE3 del contenido propio de la celda — clase + fuente.
+    /// No incluye dependencias; eso es el [`crate::Notebook::digest`].
+    pub fn content_hash(&self) -> [u8; 32] {
+        let mut h = blake3::Hasher::new();
+        h.update(self.kind.tag().as_bytes());
+        h.update(b"\0");
+        match &self.kind {
+            CellKind::Code { language } => {
+                h.update(language.as_bytes());
+            }
+            CellKind::Embed { module } => {
+                h.update(module.as_bytes());
+            }
+            CellKind::Markdown => {}
+        }
+        h.update(b"\0");
+        h.update(self.source.as_bytes());
+        *h.finalize().as_bytes()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn cell(kind: CellKind, source: &str) -> Cell {
+        Cell { id: 1, kind, source: source.into(), depends_on: vec![], state: CellState::Stale }
+    }
+
+    #[test]
+    fn same_content_hashes_equal() {
+        let a = cell(CellKind::Markdown, "hola");
+        let b = cell(CellKind::Markdown, "hola");
+        assert_eq!(a.content_hash(), b.content_hash());
+    }
+
+    #[test]
+    fn kind_changes_the_hash() {
+        let md = cell(CellKind::Markdown, "x");
+        let code = cell(CellKind::Code { language: "rust".into() }, "x");
+        assert_ne!(md.content_hash(), code.content_hash());
+    }
+
+    #[test]
+    fn language_changes_the_hash() {
+        let rust = cell(CellKind::Code { language: "rust".into() }, "1+1");
+        let python = cell(CellKind::Code { language: "python".into() }, "1+1");
+        assert_ne!(rust.content_hash(), python.content_hash());
+    }
+}
diff --git a/crates/modules/yachay/yachay-core/src/lib.rs b/crates/modules/yachay/yachay-core/src/lib.rs
new file mode 100644
index 0000000..092c5ea
--- /dev/null
+++ b/crates/modules/yachay/yachay-core/src/lib.rs
@@ -0,0 +1,22 @@
+//! `yachay-core` — el núcleo de los notebooks reproducibles.
+//!
+//! Un notebook de yachay es a la vez una secuencia de celdas (el orden
+//! de lectura) y un DAG de dependencias (el orden de ejecución). Editar
+//! una celda marca obsoletas a sus descendientes; un digest Merkle
+//! certifica que dos corridas del mismo notebook producen lo mismo —
+//! reproducibilidad verificable, no prometida.
+//!
+//! - [`cell`] — la [`Cell`] y su clase ([`CellKind`]: markdown, código,
+//!   o un embed de otro módulo brahman).
+//! - [`notebook`] — el [`Notebook`]: DAG, staleness y digest.
+//!
+//! Sin kernel, sin ejecución real, sin UI — tipos puros. La ejecución de
+//! código y el render de los embeds van en capas superiores.
+
+#![forbid(unsafe_code)]
+
+pub mod cell;
+pub mod notebook;
+
+pub use cell::{Cell, CellId, CellKind, CellState};
+pub use notebook::Notebook;
diff --git a/crates/modules/yachay/yachay-core/src/notebook.rs b/crates/modules/yachay/yachay-core/src/notebook.rs
new file mode 100644
index 0000000..795dc30
--- /dev/null
+++ b/crates/modules/yachay/yachay-core/src/notebook.rs
@@ -0,0 +1,349 @@
+//! El notebook — celdas en orden de presentación + un DAG de dependencias.
+//!
+//! Un notebook tiene dos estructuras a la vez: el **orden de
+//! presentación** (la lista de celdas tal como se leen) y el **DAG de
+//! dependencias** (qué celda necesita el resultado de cuál). La
+//! ejecución sigue el DAG; el digest Merkle certifica que dos corridas
+//! del mismo notebook producen lo mismo.
+
+use std::collections::{BTreeMap, BTreeSet, VecDeque};
+
+use serde::{Deserialize, Serialize};
+
+use crate::cell::{Cell, CellId, CellKind, CellState};
+
+/// Un notebook reproducible.
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct Notebook {
+    /// Celdas en orden de presentación.
+    cells: Vec<Cell>,
+    next_id: CellId,
+}
+
+impl Notebook {
+    pub fn new() -> Self {
+        Self { cells: Vec::new(), next_id: 1 }
+    }
+
+    /// Añade una celda al final, sin dependencias y en estado `Stale`.
+    /// Devuelve su id.
+    pub fn push(&mut self, kind: CellKind, source: impl Into<String>) -> CellId {
+        let id = self.next_id;
+        self.next_id += 1;
+        self.cells.push(Cell {
+            id,
+            kind,
+            source: source.into(),
+            depends_on: Vec::new(),
+            state: CellState::Stale,
+        });
+        id
+    }
+
+    pub fn len(&self) -> usize {
+        self.cells.len()
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.cells.is_empty()
+    }
+
+    /// Celdas en orden de presentación.
+    pub fn cells(&self) -> &[Cell] {
+        &self.cells
+    }
+
+    pub fn cell(&self, id: CellId) -> Option<&Cell> {
+        self.cells.iter().find(|c| c.id == id)
+    }
+
+    fn cell_mut(&mut self, id: CellId) -> Option<&mut Cell> {
+        self.cells.iter_mut().find(|c| c.id == id)
+    }
+
+    /// `true` si `a` depende —directa o transitivamente— de `b`.
+    fn depends_transitively(&self, a: CellId, b: CellId) -> bool {
+        let mut seen: BTreeSet<CellId> = BTreeSet::new();
+        let mut queue: VecDeque<CellId> = VecDeque::from([a]);
+        while let Some(cur) = queue.pop_front() {
+            let Some(cell) = self.cell(cur) else { continue };
+            for &dep in &cell.depends_on {
+                if dep == b {
+                    return true;
+                }
+                if seen.insert(dep) {
+                    queue.push_back(dep);
+                }
+            }
+        }
+        false
+    }
+
+    /// Declara que `cell` depende de `dep`. Rechaza (devuelve `false`)
+    /// si alguna celda no existe o si la arista crearía un ciclo.
+    pub fn add_dependency(&mut self, cell: CellId, dep: CellId) -> bool {
+        if cell == dep || self.cell(cell).is_none() || self.cell(dep).is_none() {
+            return false;
+        }
+        // Si `dep` ya depende de `cell`, esta arista cerraría un ciclo.
+        if self.depends_transitively(dep, cell) {
+            return false;
+        }
+        let c = self.cell_mut(cell).expect("verificado");
+        if !c.depends_on.contains(&dep) {
+            c.depends_on.push(dep);
+        }
+        true
+    }
+
+    /// Reemplaza la fuente de una celda: la marca `Stale` y propaga la
+    /// obsolescencia a todas sus dependientes. Devuelve los ids marcados
+    /// (sin contar la celda misma). `false` si la celda no existe.
+    pub fn set_source(&mut self, id: CellId, source: impl Into<String>) -> bool {
+        let Some(c) = self.cell_mut(id) else {
+            return false;
+        };
+        c.source = source.into();
+        c.state = CellState::Stale;
+        self.propagate_stale(id);
+        true
+    }
+
+    /// Marca el estado de una celda. `false` si no existe.
+    pub fn set_state(&mut self, id: CellId, state: CellState) -> bool {
+        match self.cell_mut(id) {
+            Some(c) => {
+                c.state = state;
+                true
+            }
+            None => false,
+        }
+    }
+
+    /// Dependientes directos de `id`.
+    pub fn dependents(&self, id: CellId) -> Vec<CellId> {
+        self.cells
+            .iter()
+            .filter(|c| c.depends_on.contains(&id))
+            .map(|c| c.id)
+            .collect()
+    }
+
+    /// Marca `Stale` a todo dependiente transitivo de `id`. Devuelve los
+    /// ids afectados.
+    pub fn propagate_stale(&mut self, id: CellId) -> Vec<CellId> {
+        let mut affected: Vec<CellId> = Vec::new();
+        let mut seen: BTreeSet<CellId> = BTreeSet::from([id]);
+        let mut queue: VecDeque<CellId> = VecDeque::from([id]);
+        while let Some(cur) = queue.pop_front() {
+            for child in self.dependents(cur) {
+                if seen.insert(child) {
+                    if let Some(c) = self.cell_mut(child) {
+                        c.state = CellState::Stale;
+                    }
+                    affected.push(child);
+                    queue.push_back(child);
+                }
+            }
+        }
+        affected
+    }
+
+    /// Orden topológico de ejecución (dependencias antes que
+    /// dependientes). `None` si el DAG tiene un ciclo.
+    pub fn execution_order(&self) -> Option<Vec<CellId>> {
+        let mut indeg: BTreeMap<CellId, usize> =
+            self.cells.iter().map(|c| (c.id, 0usize)).collect();
+        for c in &self.cells {
+            for &dep in &c.depends_on {
+                if self.cell(dep).is_some() {
+                    *indeg.get_mut(&c.id).unwrap() += 1;
+                }
+            }
+        }
+        let mut queue: VecDeque<CellId> =
+            indeg.iter().filter(|(_, &d)| d == 0).map(|(&k, _)| k).collect();
+        let mut order: Vec<CellId> = Vec::with_capacity(self.cells.len());
+        while let Some(u) = queue.pop_front() {
+            order.push(u);
+            for child in self.dependents(u) {
+                if let Some(d) = indeg.get_mut(&child) {
+                    *d -= 1;
+                    if *d == 0 {
+                        queue.push_back(child);
+                    }
+                }
+            }
+        }
+        (order.len() == self.cells.len()).then_some(order)
+    }
+
+    /// Digest Merkle de cada celda: `blake3(content_hash ‖ digests de las
+    /// dependencias)`. Captura la celda y todo su linaje — dos notebooks
+    /// con los mismos digests producen, reproduciblemente, lo mismo.
+    /// `None` si hay un ciclo.
+    fn all_digests(&self) -> Option<BTreeMap<CellId, [u8; 32]>> {
+        let order = self.execution_order()?;
+        let mut digests: BTreeMap<CellId, [u8; 32]> = BTreeMap::new();
+        for id in order {
+            let cell = self.cell(id).expect("del orden");
+            let mut h = blake3::Hasher::new();
+            h.update(&cell.content_hash());
+            // Dependencias ordenadas → el digest no depende del orden de
+            // declaración.
+            let mut deps = cell.depends_on.clone();
+            deps.sort_unstable();
+            for dep in deps {
+                if let Some(d) = digests.get(&dep) {
+                    h.update(d);
+                }
+            }
+            digests.insert(id, *h.finalize().as_bytes());
+        }
+        Some(digests)
+    }
+
+    /// Digest reproducible de una celda concreta.
+    pub fn digest(&self, id: CellId) -> Option<[u8; 32]> {
+        self.all_digests()?.get(&id).copied()
+    }
+
+    /// Digest reproducible del notebook entero: `blake3` de los digests
+    /// de todas las celdas en orden de id. Dos notebooks con el mismo
+    /// digest son reproduciblemente equivalentes. `None` si hay ciclo.
+    pub fn notebook_digest(&self) -> Option<[u8; 32]> {
+        let digests = self.all_digests()?;
+        let mut h = blake3::Hasher::new();
+        for d in digests.values() {
+            h.update(d);
+        }
+        Some(*h.finalize().as_bytes())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn code(nb: &mut Notebook, src: &str) -> CellId {
+        nb.push(CellKind::Code { language: "rust".into() }, src)
+    }
+
+    /// Notebook a → b → c (cada uno depende del anterior).
+    fn chain() -> (Notebook, CellId, CellId, CellId) {
+        let mut nb = Notebook::new();
+        let a = code(&mut nb, "let x = 1;");
+        let b = code(&mut nb, "let y = x + 1;");
+        let c = code(&mut nb, "println!(\"{y}\");");
+        nb.add_dependency(b, a);
+        nb.add_dependency(c, b);
+        (nb, a, b, c)
+    }
+
+    #[test]
+    fn push_keeps_display_order() {
+        let (nb, a, b, c) = chain();
+        let ids: Vec<_> = nb.cells().iter().map(|x| x.id).collect();
+        assert_eq!(ids, vec![a, b, c]);
+    }
+
+    #[test]
+    fn execution_order_respects_dependencies() {
+        let (nb, a, b, c) = chain();
+        assert_eq!(nb.execution_order(), Some(vec![a, b, c]));
+    }
+
+    #[test]
+    fn add_dependency_rejects_cycles() {
+        let (mut nb, a, _b, c) = chain();
+        // a depender de c cerraría el ciclo a→b→c→a.
+        assert!(!nb.add_dependency(a, c));
+        assert!(nb.execution_order().is_some());
+    }
+
+    #[test]
+    fn add_dependency_rejects_self_and_missing() {
+        let (mut nb, a, ..) = chain();
+        assert!(!nb.add_dependency(a, a));
+        assert!(!nb.add_dependency(a, 999));
+    }
+
+    #[test]
+    fn editing_a_cell_propagates_staleness() {
+        let (mut nb, a, b, c) = chain();
+        for id in [a, b, c] {
+            nb.set_state(id, CellState::Fresh);
+        }
+        nb.set_source(a, "let x = 42;");
+        // La celda editada y sus descendientes quedan Stale.
+        assert_eq!(nb.cell(a).unwrap().state, CellState::Stale);
+        assert_eq!(nb.cell(b).unwrap().state, CellState::Stale);
+        assert_eq!(nb.cell(c).unwrap().state, CellState::Stale);
+    }
+
+    #[test]
+    fn editing_a_leaf_does_not_stale_its_ancestors() {
+        let (mut nb, a, b, c) = chain();
+        for id in [a, b, c] {
+            nb.set_state(id, CellState::Fresh);
+        }
+        nb.set_source(c, "println!(\"fin\");");
+        assert_eq!(nb.cell(a).unwrap().state, CellState::Fresh);
+        assert_eq!(nb.cell(b).unwrap().state, CellState::Fresh);
+        assert_eq!(nb.cell(c).unwrap().state, CellState::Stale);
+    }
+
+    #[test]
+    fn notebook_digest_is_stable_across_calls() {
+        let (nb, ..) = chain();
+        assert_eq!(nb.notebook_digest(), nb.notebook_digest());
+    }
+
+    #[test]
+    fn editing_a_source_changes_the_digest() {
+        let (mut nb, a, ..) = chain();
+        let before = nb.notebook_digest();
+        nb.set_source(a, "let x = 999;");
+        assert_ne!(before, nb.notebook_digest());
+    }
+
+    #[test]
+    fn cell_digest_reflects_upstream_changes() {
+        // Cambiar `a` cambia el digest de `c` (su descendiente).
+        let (mut nb, a, _b, c) = chain();
+        let c_before = nb.digest(c);
+        nb.set_source(a, "let x = 7;");
+        assert_ne!(c_before, nb.digest(c));
+    }
+
+    #[test]
+    fn dependency_order_does_not_affect_digest() {
+        // Dos celdas con las mismas dos dependencias, declaradas en
+        // distinto orden, dan el mismo digest.
+        let mut x = Notebook::new();
+        let xa = code(&mut x, "a");
+        let xb = code(&mut x, "b");
+        let xc = code(&mut x, "c");
+        x.add_dependency(xc, xa);
+        x.add_dependency(xc, xb);
+
+        let mut y = Notebook::new();
+        let ya = code(&mut y, "a");
+        let yb = code(&mut y, "b");
+        let yc = code(&mut y, "c");
+        y.add_dependency(yc, yb);
+        y.add_dependency(yc, ya);
+
+        assert_eq!(x.digest(xc), y.digest(yc));
+    }
+
+    #[test]
+    fn embed_cells_carry_their_module() {
+        let mut nb = Notebook::new();
+        let id = nb.push(CellKind::Embed { module: "dominium".into() }, "preset: caos");
+        assert!(matches!(
+            &nb.cell(id).unwrap().kind,
+            CellKind::Embed { module } if module == "dominium"
+        ));
+    }
+}