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>
This commit is contained in:
Generated
+15
@@ -14875,6 +14875,21 @@ version = "0.8.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
|
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yachay"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"yachay-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yachay-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"blake3",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yamux"
|
name = "yamux"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
|
|||||||
@@ -143,6 +143,11 @@ members = [
|
|||||||
"crates/modules/matilda/matilda-config",
|
"crates/modules/matilda/matilda-config",
|
||||||
"crates/modules/matilda/matilda-plan",
|
"crates/modules/matilda/matilda-plan",
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# modules/yachay/ — Notebooks computacionales reproducibles
|
||||||
|
# ============================================================
|
||||||
|
"crates/modules/yachay/yachay-core",
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# modules/nakui/ — ERP matemático (categórico)
|
# modules/nakui/ — ERP matemático (categórico)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -256,6 +261,7 @@ members = [
|
|||||||
"crates/apps/agorapura",
|
"crates/apps/agorapura",
|
||||||
"crates/apps/badu",
|
"crates/apps/badu",
|
||||||
"crates/apps/matilda",
|
"crates/apps/matilda",
|
||||||
|
"crates/apps/yachay",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -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" }
|
||||||
@@ -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 { "✘" }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user