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>
This commit is contained in:
Generated
+16
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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 }
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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(¤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<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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user