From e3980d005f2096694d54da0f5f3fc76fac8ae914 Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 20 May 2026 17:09:18 +0000 Subject: [PATCH] =?UTF-8?q?feat(yachay):=20notebooks=20reproducibles=20?= =?UTF-8?q?=E2=80=94=20yachay-core=20+=20demo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.lock | 15 + Cargo.toml | 6 + crates/apps/yachay/Cargo.toml | 16 + crates/apps/yachay/src/main.rs | 91 +++++ crates/modules/yachay/SDD.md | 46 +++ crates/modules/yachay/yachay-core/Cargo.toml | 12 + crates/modules/yachay/yachay-core/src/cell.rs | 104 ++++++ crates/modules/yachay/yachay-core/src/lib.rs | 22 ++ .../yachay/yachay-core/src/notebook.rs | 349 ++++++++++++++++++ 9 files changed, 661 insertions(+) create mode 100644 crates/apps/yachay/Cargo.toml create mode 100644 crates/apps/yachay/src/main.rs create mode 100644 crates/modules/yachay/SDD.md create mode 100644 crates/modules/yachay/yachay-core/Cargo.toml create mode 100644 crates/modules/yachay/yachay-core/src/cell.rs create mode 100644 crates/modules/yachay/yachay-core/src/lib.rs create mode 100644 crates/modules/yachay/yachay-core/src/notebook.rs 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::() / 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::>() { + 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, + 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, + 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) -> 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 = BTreeSet::new(); + let mut queue: VecDeque = 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) -> 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 { + 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 { + let mut affected: Vec = Vec::new(); + let mut seen: BTreeSet = BTreeSet::from([id]); + let mut queue: VecDeque = 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> { + let mut indeg: BTreeMap = + 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 = + indeg.iter().filter(|(_, &d)| d == 0).map(|(&k, _)| k).collect(); + let mut order: Vec = 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> { + let order = self.execution_order()?; + let mut digests: BTreeMap = 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" + )); + } +}