tawasuyu

tawasuyu/brahman

Rust 467TOML 184Markdown 49JSON 176411 Sutichaykuna

feat(fana): fana-render-plan — plan de dibujo agnóstico del editor DAG

build_plan(NarrativeGraph) → RenderPlan: AtomBlocks apilados por
profundidad topológica (una columna por rama), Edges de dependencia
(borde inferior → superior) y osciloscopio de coherencia en el
sidepane (tono + intensidad semántica normalizada). Determinista:
orden desempata por (profundidad, columna, id). 10 tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

494fb7c0bcda68369ba343f04371715b391a70b8 · ruwaq sergio · (5 killa ñawpaq) · tayta 781a310c8 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 24

crates/modules/fana/fana-render-plan/src/lib.rs

4 qillqakuna · +412 −0

diff --git a/Cargo.lock b/Cargo.lock
index 1b8d2fe..c9e1546 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4034,6 +4034,16 @@ dependencies = [
  "web-sys",
 ]
 
+[[package]]
+name = "fana-render-plan"
+version = "0.1.0"
+dependencies = [
+ "fana-core",
+ "fana-graph",
+ "serde",
+ "uuid",
+]
+
 [[package]]
 name = "fana-semantic"
 version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index 4b27245..7a97a82 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -166,6 +166,7 @@ members = [
     # ============================================================
     "crates/modules/fana/fana-core",
     "crates/modules/fana/fana-graph",
+    "crates/modules/fana/fana-render-plan",
     "crates/modules/fana/fana-store",
     "crates/modules/fana/fana-semantic",
     "crates/modules/fana/fana-md",
diff --git a/crates/modules/fana/fana-render-plan/Cargo.toml b/crates/modules/fana/fana-render-plan/Cargo.toml
new file mode 100644
index 0000000..77ce9c2
--- /dev/null
+++ b/crates/modules/fana/fana-render-plan/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "fana-render-plan"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "fana — plan de dibujo agnóstico del editor DAG: layout de átomos en columnas por rama, conectores de dependencia y osciloscopio de coherencia en el sidepane."
+
+[dependencies]
+fana-core = { path = "../fana-core" }
+fana-graph = { path = "../fana-graph" }
+serde = { workspace = true }
+uuid = { workspace = true }
diff --git a/crates/modules/fana/fana-render-plan/src/lib.rs b/crates/modules/fana/fana-render-plan/src/lib.rs
new file mode 100644
index 0000000..21d4c23
--- /dev/null
+++ b/crates/modules/fana/fana-render-plan/src/lib.rs
@@ -0,0 +1,387 @@
+//! `fana-render-plan` — el plan de dibujo del editor DAG, agnóstico.
+//!
+//! Traduce un [`NarrativeGraph`] a una geometría 2D lista para pintar
+//! sin saber nada del backend (`fana-editor-gpui`, `fana-editor-web`):
+//!
+//! - **Editor** — un [`AtomBlock`] por átomo, apilados verticalmente en
+//!   orden topológico; cada rama ocupa su propia columna.
+//! - **Conectores** — un [`Edge`] por arista de dependencia, del borde
+//!   inferior del prerrequisito al superior del dependiente.
+//! - **Osciloscopio** — un [`SidepaneMark`] por átomo en el sidepane,
+//!   coloreado por coherencia y con altura según la intensidad
+//!   semántica acumulada.
+//!
+//! Todo es determinista: el orden de layout se desempata por
+//! `(profundidad, columna, id)`, sin depender de la iteración de
+//! `HashMap`.
+
+#![forbid(unsafe_code)]
+
+use std::collections::HashMap;
+
+use fana_core::CoherenceState;
+use fana_graph::NarrativeGraph;
+use serde::{Deserialize, Serialize};
+use uuid::Uuid;
+
+/// Coherencia de un átomo reducida a un tono de presentación.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum CoherenceTone {
+    /// `Valid` — consistente.
+    Valid,
+    /// `PendingEvaluation` — una dependencia mutó, falta verificar.
+    Pending,
+    /// `InConflict` — una dependencia lo contradice.
+    Conflict,
+}
+
+impl CoherenceTone {
+    fn of(state: &CoherenceState) -> Self {
+        match state {
+            CoherenceState::Valid => CoherenceTone::Valid,
+            CoherenceState::PendingEvaluation => CoherenceTone::Pending,
+            CoherenceState::InConflict { .. } => CoherenceTone::Conflict,
+        }
+    }
+}
+
+/// Un bloque del editor — la caja visual de un átomo narrativo.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct AtomBlock {
+    pub id: Uuid,
+    pub x: f32,
+    pub y: f32,
+    pub w: f32,
+    pub h: f32,
+    /// Rama / línea temporal del átomo.
+    pub branch: String,
+    /// Profundidad topológica (0 = raíz).
+    pub depth: usize,
+    pub tone: CoherenceTone,
+    /// Primeros caracteres del contenido (con `…` si se truncó).
+    pub preview: String,
+}
+
+/// Un conector de dependencia entre dos bloques.
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
+pub struct Edge {
+    pub from: Uuid,
+    pub to: Uuid,
+    /// Punto de salida (borde inferior del prerrequisito).
+    pub x1: f32,
+    pub y1: f32,
+    /// Punto de llegada (borde superior del dependiente).
+    pub x2: f32,
+    pub y2: f32,
+}
+
+/// Una marca del osciloscopio de coherencia, en el sidepane.
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
+pub struct SidepaneMark {
+    pub id: Uuid,
+    pub y: f32,
+    pub h: f32,
+    pub tone: CoherenceTone,
+    /// Intensidad semántica normalizada a `0.0..=1.0` sobre el documento.
+    pub intensity: f32,
+}
+
+/// Parámetros de layout — lo que un panel de presentación ajustaría.
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
+pub struct LayoutConfig {
+    pub block_w: f32,
+    pub block_h: f32,
+    /// Espacio vertical entre bloques consecutivos.
+    pub gap: f32,
+    /// Margen alrededor del lienzo.
+    pub margin: f32,
+    /// Desplazamiento horizontal entre columnas de ramas.
+    pub column_stride: f32,
+    pub sidepane_width: f32,
+    /// Cuántos caracteres del contenido entran en el `preview`.
+    pub preview_chars: usize,
+}
+
+impl Default for LayoutConfig {
+    fn default() -> Self {
+        Self {
+            block_w: 360.0,
+            block_h: 64.0,
+            gap: 14.0,
+            margin: 24.0,
+            column_stride: 400.0,
+            sidepane_width: 120.0,
+            preview_chars: 80,
+        }
+    }
+}
+
+/// El plan de dibujo completo del documento.
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct RenderPlan {
+    pub blocks: Vec<AtomBlock>,
+    pub edges: Vec<Edge>,
+    pub sidepane: Vec<SidepaneMark>,
+    /// Alto total del contenido — el backend lo usa para el scroll.
+    pub content_height: f32,
+}
+
+/// Profundidad topológica de cada átomo: 0 para las raíces, `1 + máx`
+/// de las profundidades de sus dependencias. Si el grafo tiene un ciclo
+/// (no debería) todos caen a 0.
+fn depths(graph: &NarrativeGraph) -> HashMap<Uuid, usize> {
+    let mut depth: HashMap<Uuid, usize> = HashMap::new();
+    let Some(order) = graph.topological_order() else {
+        // Grafo con ciclo: layout plano defensivo.
+        return graph.atoms().map(|a| (a.id, 0)).collect();
+    };
+    for id in order {
+        let Some(atom) = graph.get(id) else { continue };
+        let d = atom
+            .dependencies
+            .iter()
+            .filter_map(|dep| depth.get(dep))
+            .map(|&d| d + 1)
+            .max()
+            .unwrap_or(0);
+        depth.insert(id, d);
+    }
+    depth
+}
+
+/// Recorta `content` a `max` caracteres, añadiendo `…` si se truncó.
+fn preview(content: &str, max: usize) -> String {
+    let mut out: String = content.chars().take(max).collect();
+    if content.chars().count() > max {
+        out.push('…');
+    }
+    out
+}
+
+/// Intensidad semántica de un átomo: suma de los valores absolutos de
+/// sus vectores concepto→intensidad.
+fn raw_intensity(atom: &fana_core::NarrativeAtom) -> f32 {
+    atom.semantic_vectors.values().map(|v| v.abs()).sum()
+}
+
+/// Construye el plan de dibujo de un `NarrativeGraph`.
+pub fn build_plan(graph: &NarrativeGraph, cfg: &LayoutConfig) -> RenderPlan {
+    if graph.is_empty() {
+        return RenderPlan::default();
+    }
+
+    let depth = depths(graph);
+
+    // Columnas: una por rama, ordenadas alfabéticamente para estabilidad.
+    let mut branches: Vec<&str> = graph.atoms().map(|a| a.branch_id.as_str()).collect();
+    branches.sort_unstable();
+    branches.dedup();
+    let column: HashMap<&str, usize> =
+        branches.iter().enumerate().map(|(i, &b)| (b, i)).collect();
+
+    // Orden de layout determinista: (profundidad, columna, id).
+    let mut order: Vec<&fana_core::NarrativeAtom> = graph.atoms().collect();
+    order.sort_by(|a, b| {
+        let da = depth.get(&a.id).copied().unwrap_or(0);
+        let db = depth.get(&b.id).copied().unwrap_or(0);
+        let ca = column[a.branch_id.as_str()];
+        let cb = column[b.branch_id.as_str()];
+        da.cmp(&db).then(ca.cmp(&cb)).then(a.id.cmp(&b.id))
+    });
+
+    let row_stride = cfg.block_h + cfg.gap;
+    let max_intensity = order
+        .iter()
+        .map(|a| raw_intensity(a))
+        .fold(0.0f32, f32::max);
+
+    let mut blocks = Vec::with_capacity(order.len());
+    let mut sidepane = Vec::with_capacity(order.len());
+    let mut rect: HashMap<Uuid, (f32, f32, f32, f32)> = HashMap::new();
+
+    for (row, atom) in order.iter().enumerate() {
+        let col = column[atom.branch_id.as_str()];
+        let x = cfg.margin + cfg.sidepane_width + col as f32 * cfg.column_stride;
+        let y = cfg.margin + row as f32 * row_stride;
+        let d = depth.get(&atom.id).copied().unwrap_or(0);
+        rect.insert(atom.id, (x, y, cfg.block_w, cfg.block_h));
+
+        blocks.push(AtomBlock {
+            id: atom.id,
+            x,
+            y,
+            w: cfg.block_w,
+            h: cfg.block_h,
+            branch: atom.branch_id.clone(),
+            depth: d,
+            tone: CoherenceTone::of(&atom.coherence),
+            preview: preview(&atom.content, cfg.preview_chars),
+        });
+
+        let intensity = if max_intensity > 0.0 {
+            raw_intensity(atom) / max_intensity
+        } else {
+            0.0
+        };
+        sidepane.push(SidepaneMark {
+            id: atom.id,
+            y,
+            h: cfg.block_h,
+            tone: CoherenceTone::of(&atom.coherence),
+            intensity,
+        });
+    }
+
+    // Conectores: una arista por dependencia presente en el grafo.
+    let mut edges = Vec::new();
+    for atom in &order {
+        let Some(&(tx, ty, tw, _)) = rect.get(&atom.id) else { continue };
+        for dep in &atom.dependencies {
+            let Some(&(fx, fy, fw, fh)) = rect.get(dep) else { continue };
+            edges.push(Edge {
+                from: *dep,
+                to: atom.id,
+                x1: fx + fw * 0.5,
+                y1: fy + fh,
+                x2: tx + tw * 0.5,
+                y2: ty,
+            });
+        }
+    }
+
+    RenderPlan {
+        blocks,
+        edges,
+        sidepane,
+        content_height: cfg.margin * 2.0 + order.len() as f32 * row_stride,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use fana_core::NarrativeAtom;
+
+    /// Cadena a → b → c en la rama `main`.
+    fn chain() -> (NarrativeGraph, Uuid, Uuid, Uuid) {
+        let a = NarrativeAtom::new("primero", "main");
+        let a_id = a.id;
+        let b = NarrativeAtom::new("segundo", "main").depends_on(a_id);
+        let b_id = b.id;
+        let c = NarrativeAtom::new("tercero", "main").depends_on(b_id);
+        let c_id = c.id;
+        (NarrativeGraph::from_atoms([a, b, c]), a_id, b_id, c_id)
+    }
+
+    #[test]
+    fn empty_graph_yields_empty_plan() {
+        let plan = build_plan(&NarrativeGraph::new(), &LayoutConfig::default());
+        assert!(plan.blocks.is_empty() && plan.edges.is_empty());
+        assert_eq!(plan.content_height, 0.0);
+    }
+
+    #[test]
+    fn one_block_and_one_mark_per_atom() {
+        let (g, ..) = chain();
+        let plan = build_plan(&g, &LayoutConfig::default());
+        assert_eq!(plan.blocks.len(), 3);
+        assert_eq!(plan.sidepane.len(), 3);
+    }
+
+    #[test]
+    fn chain_has_one_edge_per_dependency() {
+        let (g, ..) = chain();
+        let plan = build_plan(&g, &LayoutConfig::default());
+        assert_eq!(plan.edges.len(), 2);
+    }
+
+    #[test]
+    fn blocks_are_stacked_by_topological_depth() {
+        let (g, a, b, c) = chain();
+        let plan = build_plan(&g, &LayoutConfig::default());
+        let y = |id: Uuid| plan.blocks.iter().find(|bl| bl.id == id).unwrap().y;
+        assert!(y(a) < y(b), "a antes que b");
+        assert!(y(b) < y(c), "b antes que c");
+        let depth = |id: Uuid| plan.blocks.iter().find(|bl| bl.id == id).unwrap().depth;
+        assert_eq!((depth(a), depth(b), depth(c)), (0, 1, 2));
+    }
+
+    #[test]
+    fn edge_connects_dependency_bottom_to_dependent_top() {
+        let (g, a, b, _) = chain();
+        let plan = build_plan(&g, &LayoutConfig::default());
+        let e = plan.edges.iter().find(|e| e.from == a && e.to == b).unwrap();
+        let block_a = plan.blocks.iter().find(|bl| bl.id == a).unwrap();
+        let block_b = plan.blocks.iter().find(|bl| bl.id == b).unwrap();
+        assert_eq!(e.y1, block_a.y + block_a.h); // borde inferior de a
+        assert_eq!(e.y2, block_b.y); // borde superior de b
+    }
+
+    #[test]
+    fn coherence_states_map_to_tones() {
+        let mut conflicted = NarrativeAtom::new("roto", "main");
+        conflicted.coherence = CoherenceState::InConflict {
+            origin: Uuid::new_v4(),
+            reason: "contradice el origen".into(),
+        };
+        let mut pending = NarrativeAtom::new("dudoso", "main");
+        pending.coherence = CoherenceState::PendingEvaluation;
+        let valid = NarrativeAtom::new("ok", "main");
+        let (cid, pid, vid) = (conflicted.id, pending.id, valid.id);
+        let g = NarrativeGraph::from_atoms([conflicted, pending, valid]);
+        let plan = build_plan(&g, &LayoutConfig::default());
+        let tone = |id: Uuid| plan.blocks.iter().find(|b| b.id == id).unwrap().tone;
+        assert_eq!(tone(cid), CoherenceTone::Conflict);
+        assert_eq!(tone(pid), CoherenceTone::Pending);
+        assert_eq!(tone(vid), CoherenceTone::Valid);
+    }
+
+    #[test]
+    fn branches_land_in_separate_columns() {
+        let main = NarrativeAtom::new("línea principal", "main");
+        let alt = NarrativeAtom::new("línea alterna", "alt");
+        let (mid, aid) = (main.id, alt.id);
+        let g = NarrativeGraph::from_atoms([main, alt]);
+        let plan = build_plan(&g, &LayoutConfig::default());
+        let x = |id: Uuid| plan.blocks.iter().find(|b| b.id == id).unwrap().x;
+        assert_ne!(x(mid), x(aid), "ramas distintas → columnas distintas");
+    }
+
+    #[test]
+    fn preview_truncates_long_content() {
+        let long = "x".repeat(500);
+        let atom = NarrativeAtom::new(long, "main");
+        let g = NarrativeGraph::from_atoms([atom]);
+        let cfg = LayoutConfig { preview_chars: 20, ..LayoutConfig::default() };
+        let plan = build_plan(&g, &cfg);
+        let p = &plan.blocks[0].preview;
+        assert!(p.ends_with('…'));
+        assert_eq!(p.chars().count(), 21); // 20 + el elipsis
+    }
+
+    #[test]
+    fn intensity_is_normalized_to_unit_range() {
+        let mut weak = NarrativeAtom::new("tenue", "main");
+        weak.semantic_vectors.insert("miedo".into(), 0.2);
+        let mut strong = NarrativeAtom::new("intenso", "main");
+        strong.semantic_vectors.insert("miedo".into(), 0.8);
+        strong.semantic_vectors.insert("ira".into(), 1.2);
+        let (wid, sid) = (weak.id, strong.id);
+        let g = NarrativeGraph::from_atoms([weak, strong]);
+        let plan = build_plan(&g, &LayoutConfig::default());
+        let inten = |id: Uuid| plan.sidepane.iter().find(|m| m.id == id).unwrap().intensity;
+        // El más intenso queda en 1.0; el resto, proporcional.
+        assert!((inten(sid) - 1.0).abs() < 1e-6);
+        assert!(inten(wid) < inten(sid));
+    }
+
+    #[test]
+    fn plan_is_deterministic() {
+        let (g, ..) = chain();
+        let a = build_plan(&g, &LayoutConfig::default());
+        let b = build_plan(&g, &LayoutConfig::default());
+        assert_eq!(a.blocks, b.blocks);
+        assert_eq!(a.edges, b.edges);
+        assert_eq!(a.sidepane, b.sidepane);
+    }
+}