tawasuyu

tawasuyu/brahman

Rust 332TOML 127JSON 17Markdown 154710 Sutichaykuna

feat(fana): C1 — núcleo del writer DAG editor (core + graph)

Primer paso de fana (prioridad alta entre las apps Fase C).

- fana-core — NarrativeAtom: id + content_hash SHA-256 + content
  Arc<String> (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 <noreply@anthropic.com>

353e0bbb43e692ce0fe7077b946b3481c7e4653a · ruwaq sergio · (5 killa ñawpaq) · tayta 05886022e · kay commitpi sach'ata qhaway

Semántico sayk'uy · 31

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

crates/modules/fana/fana-graph/src/lib.rs

6 qillqakuna · +376 −0

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<String>` 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<String>,
+    /// Concepto → intensidad. Lo puebla `fana-semantic`.
+    pub semantic_vectors: HashMap<String, f32>,
+    /// Átomos prerrequisito (sus "padres" lógicos).
+    pub dependencies: Vec<Uuid>,
+    /// 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<String>, branch_id: impl Into<String>) -> 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<String>) {
+        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<Uuid, NarrativeAtom>,
+    /// `dependencia → [átomos que dependen de ella]`.
+    adjacency: HashMap<Uuid, Vec<Uuid>>,
+}
+
+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<Uuid> {
+        let mut affected = Vec::new();
+        let mut seen: HashSet<Uuid> = HashSet::new();
+        let mut queue: VecDeque<Uuid> = VecDeque::new();
+        queue.push_back(origin);
+        seen.insert(origin);
+
+        while let Some(current) = queue.pop_front() {
+            let children: Vec<Uuid> = self
+                .adjacency
+                .get(&current)
+                .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<Vec<Uuid>> {
+        let mut indeg: HashMap<Uuid, usize> = 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<Uuid> =
+            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());
+    }
+}