From 494fb7c0bcda68369ba343f04371715b391a70b8 Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 20 May 2026 16:28:27 +0000 Subject: [PATCH] =?UTF-8?q?feat(fana):=20fana-render-plan=20=E2=80=94=20pl?= =?UTF-8?q?an=20de=20dibujo=20agn=C3=B3stico=20del=20editor=20DAG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.lock | 10 + Cargo.toml | 1 + .../modules/fana/fana-render-plan/Cargo.toml | 14 + .../modules/fana/fana-render-plan/src/lib.rs | 387 ++++++++++++++++++ 4 files changed, 412 insertions(+) create mode 100644 crates/modules/fana/fana-render-plan/Cargo.toml create mode 100644 crates/modules/fana/fana-render-plan/src/lib.rs 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, + pub edges: Vec, + pub sidepane: Vec, + /// 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 { + let mut depth: HashMap = 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 = 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); + } +}