From 353e0bbb43e692ce0fe7077b946b3481c7e4653a Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 20 May 2026 15:41:17 +0000 Subject: [PATCH] =?UTF-8?q?feat(fana):=20C1=20=E2=80=94=20n=C3=BAcleo=20de?= =?UTF-8?q?l=20writer=20DAG=20editor=20(core=20+=20graph)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Primer paso de fana (prioridad alta entre las apps Fase C). - fana-core — NarrativeAtom: id + content_hash SHA-256 + content Arc (structural sharing: ramificar es O(1)) + semantic_vectors + dependencies + branch_id + CoherenceState (Valid/InConflict/ PendingEvaluation). Invariante hash↔content verificable; set_content re-hashea y marca PendingEvaluation. - fana-graph — NarrativeGraph: DAG de átomos + adjacency dependencia→dependientes. propagate_mutation: BFS que marca PendingEvaluation en cascada a todo descendiente (la "onda de choque lógica" de la spec), agnóstico de UI — devuelve los ids afectados. topological_order con detección de ciclo. 10 tests verdes. cargo check --workspace verde. Pendiente fana: semantic (cliente verbo), store (sled), llm, render-plan, editor-gpui. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 16 ++ Cargo.toml | 2 + crates/modules/fana/fana-core/Cargo.toml | 12 ++ crates/modules/fana/fana-core/src/lib.rs | 137 +++++++++++++++ crates/modules/fana/fana-graph/Cargo.toml | 12 ++ crates/modules/fana/fana-graph/src/lib.rs | 197 ++++++++++++++++++++++ 6 files changed, 376 insertions(+) create mode 100644 crates/modules/fana/fana-core/Cargo.toml create mode 100644 crates/modules/fana/fana-core/src/lib.rs create mode 100644 crates/modules/fana/fana-graph/Cargo.toml create mode 100644 crates/modules/fana/fana-graph/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 5e4d3d0..fd43879 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3945,6 +3945,22 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fana-core" +version = "0.1.0" +dependencies = [ + "sha2", + "uuid", +] + +[[package]] +name = "fana-graph" +version = "0.1.0" +dependencies = [ + "fana-core", + "uuid", +] + [[package]] name = "fana-md" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index c3cf1ac..404e504 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -146,6 +146,8 @@ members = [ # ============================================================ # modules/fana/ — Writer DAG editor (absorbe pluma) # ============================================================ + "crates/modules/fana/fana-core", + "crates/modules/fana/fana-graph", "crates/modules/fana/fana-md", "crates/modules/fana/fana-md-reader-web", diff --git a/crates/modules/fana/fana-core/Cargo.toml b/crates/modules/fana/fana-core/Cargo.toml new file mode 100644 index 0000000..d77a82f --- /dev/null +++ b/crates/modules/fana/fana-core/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "fana-core" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "fana — átomo narrativo (NarrativeAtom) + estado de coherencia. Tipos puros del editor DAG, agnósticos de UI." + +[dependencies] +uuid = { workspace = true } +sha2 = { workspace = true } diff --git a/crates/modules/fana/fana-core/src/lib.rs b/crates/modules/fana/fana-core/src/lib.rs new file mode 100644 index 0000000..fb8e3bb --- /dev/null +++ b/crates/modules/fana/fana-core/src/lib.rs @@ -0,0 +1,137 @@ +//! `fana-core` — el átomo narrativo y su estado de coherencia. +//! +//! Tipos puros del editor DAG de fana: sin UI, sin storage, sin red. El +//! documento es un grafo de [`NarrativeAtom`]s; cada átomo comparte su +//! texto vía `Arc` para que ramificar una línea temporal sea +//! O(1) (structural sharing). +//! +//! Invariante: `content_hash` siempre corresponde a `content` — +//! ver [`NarrativeAtom::hash_matches`]. + +#![forbid(unsafe_code)] + +use std::collections::HashMap; +use std::sync::Arc; +use uuid::Uuid; + +/// Estado de coherencia lógica de un átomo dentro del grafo narrativo. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CoherenceState { + /// Consistente con sus dependencias. + Valid, + /// En conflicto: una dependencia cambió y lo contradice. + InConflict { origin: Uuid, reason: String }, + /// Marcado para re-evaluación (una dependencia mutó; falta verificar). + PendingEvaluation, +} + +/// Un átomo narrativo: la unidad atómica del documento. +#[derive(Debug, Clone)] +pub struct NarrativeAtom { + pub id: Uuid, + /// SHA-256 del contenido — verifica integridad de toda mutación. + pub content_hash: [u8; 32], + /// Texto compartido. Clonar una rama no duplica el texto. + pub content: Arc, + /// Concepto → intensidad. Lo puebla `fana-semantic`. + pub semantic_vectors: HashMap, + /// Átomos prerrequisito (sus "padres" lógicos). + pub dependencies: Vec, + /// Identificador de la rama / línea temporal. + pub branch_id: String, + pub coherence: CoherenceState, +} + +impl NarrativeAtom { + /// Crea un átomo nuevo con id aleatorio. Hashea el contenido. + pub fn new(content: impl Into, branch_id: impl Into) -> Self { + let content = content.into(); + let content_hash = sha256(content.as_bytes()); + Self { + id: Uuid::new_v4(), + content_hash, + content: Arc::new(content), + semantic_vectors: HashMap::new(), + dependencies: Vec::new(), + branch_id: branch_id.into(), + coherence: CoherenceState::Valid, + } + } + + /// Declara una dependencia (prerrequisito lógico). + pub fn depends_on(mut self, dep: Uuid) -> Self { + if !self.dependencies.contains(&dep) { + self.dependencies.push(dep); + } + self + } + + /// Reemplaza el contenido: re-hashea y vuelve a `PendingEvaluation` + /// (toda mutación exige re-verificar la coherencia). + pub fn set_content(&mut self, content: impl Into) { + let content = content.into(); + self.content_hash = sha256(content.as_bytes()); + self.content = Arc::new(content); + self.coherence = CoherenceState::PendingEvaluation; + } + + /// `true` si `content_hash` corresponde al `content` actual. + /// El editor valida esto en toda mutación de texto. + pub fn hash_matches(&self) -> bool { + sha256(self.content.as_bytes()) == self.content_hash + } +} + +/// SHA-256 de un buffer de bytes. +pub fn sha256(bytes: &[u8]) -> [u8; 32] { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(bytes); + h.finalize().into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_atom_is_valid_with_matching_hash() { + let a = NarrativeAtom::new("había una vez", "main"); + assert_eq!(a.coherence, CoherenceState::Valid); + assert!(a.hash_matches()); + assert_eq!(a.branch_id, "main"); + } + + #[test] + fn set_content_rehashes_and_marks_pending() { + let mut a = NarrativeAtom::new("v1", "main"); + let h1 = a.content_hash; + a.set_content("v2 distinto"); + assert_ne!(a.content_hash, h1); + assert!(a.hash_matches()); + assert_eq!(a.coherence, CoherenceState::PendingEvaluation); + } + + #[test] + fn branch_shares_content_arc() { + let a = NarrativeAtom::new("texto largo compartido", "main"); + let b = a.clone(); + // Clonar la rama NO duplica el String — comparten el Arc. + assert!(Arc::ptr_eq(&a.content, &b.content)); + } + + #[test] + fn depends_on_dedups() { + let d = Uuid::new_v4(); + let a = NarrativeAtom::new("x", "main").depends_on(d).depends_on(d); + assert_eq!(a.dependencies.len(), 1); + } + + #[test] + fn tampered_content_fails_hash_check() { + let mut a = NarrativeAtom::new("original", "main"); + // Forzar desincronización (lo que el editor debe detectar). + a.content = Arc::new("manipulado".to_string()); + assert!(!a.hash_matches()); + } +} diff --git a/crates/modules/fana/fana-graph/Cargo.toml b/crates/modules/fana/fana-graph/Cargo.toml new file mode 100644 index 0000000..2ace2e5 --- /dev/null +++ b/crates/modules/fana/fana-graph/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "fana-graph" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "fana — grafo narrativo (DAG de NarrativeAtoms): inserción, orden topológico y propagación de mutaciones en cascada." + +[dependencies] +fana-core = { path = "../fana-core" } +uuid = { workspace = true } diff --git a/crates/modules/fana/fana-graph/src/lib.rs b/crates/modules/fana/fana-graph/src/lib.rs new file mode 100644 index 0000000..d3f8bd7 --- /dev/null +++ b/crates/modules/fana/fana-graph/src/lib.rs @@ -0,0 +1,197 @@ +//! `fana-graph` — el grafo narrativo (DAG de `NarrativeAtom`s). +//! +//! Mantiene los átomos + una adjacency list `dependencia → dependientes`. +//! Cuando un átomo muta, [`NarrativeGraph::propagate_mutation`] marca en +//! cascada a todo descendiente como `PendingEvaluation` — la "onda de +//! choque lógica" de la spec. Agnóstico de UI: devuelve los ids +//! afectados; el front-end decide cuándo re-renderizar. + +#![forbid(unsafe_code)] + +use fana_core::{CoherenceState, NarrativeAtom}; +use std::collections::{HashMap, HashSet, VecDeque}; +use uuid::Uuid; + +/// El documento como grafo dirigido acíclico de átomos narrativos. +#[derive(Debug, Default)] +pub struct NarrativeGraph { + nodes: HashMap, + /// `dependencia → [átomos que dependen de ella]`. + adjacency: HashMap>, +} + +impl NarrativeGraph { + pub fn new() -> Self { + Self::default() + } + + pub fn len(&self) -> usize { + self.nodes.len() + } + + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() + } + + pub fn contains(&self, id: Uuid) -> bool { + self.nodes.contains_key(&id) + } + + pub fn get(&self, id: Uuid) -> Option<&NarrativeAtom> { + self.nodes.get(&id) + } + + pub fn get_mut(&mut self, id: Uuid) -> Option<&mut NarrativeAtom> { + self.nodes.get_mut(&id) + } + + /// Inserta un átomo y conecta las aristas desde sus dependencias. + pub fn insert(&mut self, atom: NarrativeAtom) { + let id = atom.id; + for &dep in &atom.dependencies { + let children = self.adjacency.entry(dep).or_default(); + if !children.contains(&id) { + children.push(id); + } + } + self.nodes.insert(id, atom); + } + + /// Dependientes directos de `id`. + pub fn dependents(&self, id: Uuid) -> &[Uuid] { + self.adjacency.get(&id).map(|v| v.as_slice()).unwrap_or(&[]) + } + + /// Propaga una mutación: marca `PendingEvaluation` en TODO descendiente + /// transitivo de `origin` (BFS sobre la adjacency). Devuelve los ids + /// afectados — el caller (front-end) decide cuándo re-renderizar. + /// + /// `origin` mismo no se marca (es la fuente; ya se sabe que cambió). + pub fn propagate_mutation(&mut self, origin: Uuid) -> Vec { + let mut affected = Vec::new(); + let mut seen: HashSet = HashSet::new(); + let mut queue: VecDeque = VecDeque::new(); + queue.push_back(origin); + seen.insert(origin); + + while let Some(current) = queue.pop_front() { + let children: Vec = self + .adjacency + .get(¤t) + .cloned() + .unwrap_or_default(); + for child in children { + if seen.insert(child) { + if let Some(node) = self.nodes.get_mut(&child) { + node.coherence = CoherenceState::PendingEvaluation; + } + affected.push(child); + queue.push_back(child); + } + } + } + affected + } + + /// Orden topológico de los átomos (dependencias antes que dependientes). + /// `None` si el grafo tiene un ciclo (no es un DAG válido). + pub fn topological_order(&self) -> Option> { + let mut indeg: HashMap = self.nodes.keys().map(|&k| (k, 0)).collect(); + for atom in self.nodes.values() { + for &dep in &atom.dependencies { + if self.nodes.contains_key(&dep) { + *indeg.entry(atom.id).or_insert(0) += 1; + } + } + } + let mut queue: VecDeque = + indeg.iter().filter(|(_, &d)| d == 0).map(|(&k, _)| k).collect(); + let mut order = Vec::with_capacity(self.nodes.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); + } + } + } + } + if order.len() == self.nodes.len() { + Some(order) + } else { + None // quedó un ciclo + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Construye una cadena a → b → c y devuelve sus ids. + fn chain() -> (NarrativeGraph, Uuid, Uuid, Uuid) { + let mut g = NarrativeGraph::new(); + let a = NarrativeAtom::new("a", "main"); + let ( a_id,) = (a.id,); + let b = NarrativeAtom::new("b", "main").depends_on(a_id); + let b_id = b.id; + let c = NarrativeAtom::new("c", "main").depends_on(b_id); + let c_id = c.id; + g.insert(a); + g.insert(b); + g.insert(c); + (g, a_id, b_id, c_id) + } + + #[test] + fn insert_wires_adjacency() { + let (g, a, b, c) = chain(); + assert_eq!(g.len(), 3); + assert_eq!(g.dependents(a), &[b]); + assert_eq!(g.dependents(b), &[c]); + assert!(g.dependents(c).is_empty()); + } + + #[test] + fn propagate_marks_all_descendants_pending() { + let (mut g, a, b, c) = chain(); + let affected = g.propagate_mutation(a); + assert_eq!(affected.len(), 2); + assert!(affected.contains(&b) && affected.contains(&c)); + assert_eq!(g.get(b).unwrap().coherence, CoherenceState::PendingEvaluation); + assert_eq!(g.get(c).unwrap().coherence, CoherenceState::PendingEvaluation); + // El origen NO se marca. + assert_eq!(g.get(a).unwrap().coherence, CoherenceState::Valid); + } + + #[test] + fn propagate_from_leaf_affects_nothing() { + let (mut g, _a, _b, c) = chain(); + assert!(g.propagate_mutation(c).is_empty()); + } + + #[test] + fn topological_order_respects_dependencies() { + let (g, a, b, c) = chain(); + let order = g.topological_order().expect("es un DAG"); + let pos = |id: Uuid| order.iter().position(|&x| x == id).unwrap(); + assert!(pos(a) < pos(b)); + assert!(pos(b) < pos(c)); + } + + #[test] + fn cycle_has_no_topological_order() { + // a depende de b, b depende de a. + let mut g = NarrativeGraph::new(); + let a = NarrativeAtom::new("a", "main"); + let b = NarrativeAtom::new("b", "main"); + let (a_id, b_id) = (a.id, b.id); + let a = a.depends_on(b_id); + let b = b.depends_on(a_id); + g.insert(a); + g.insert(b); + assert!(g.topological_order().is_none()); + } +}