tawasuyu

tawasuyu/pluma

Multi-canvas authoring (bundle of bodies) + reactive notebook, in Rust, on Llimphi.

Rust 193Markdown 141TOML 663959 Definitions

⎇ 30467600bcd9effa15e8338fb32d357c1edab660 00_unanchay/pluma/pluma-store/src/multilienzo.rs Rust · 496 lines · 17.0 KB History Who touched what raw

feat: pluma standalone — autoría multilienzo (haz de cuerpos) + notebook reactivo (front-door, git-dep al monorepo) 30467600b · Sergio ·

1//! `multilienzo` — persistencia de cuerpos, transformaciones y cartas.
2//!
3//! Extiende el store con tres tipos nuevos sobre el mismo `sled::Db`:
4//!
5//! - `Cuerpo` (`pluma-cuerpo`): el lienzo del haz. Clave = `cuerpo.id`.
6//! - `Transformacion` (`pluma-transform`): la receta que deriva un
7//!   cuerpo hija de una madre. Clave = `transformacion.id`.
8//! - `CartaHebras` (`pluma-align`): las hebras entre dos cuerpos.
9//!   Clave = `cuerpo_a.id || cuerpo_b.id` (32 bytes). Si la carta no
10//!   tiene `cuerpo_a`/`cuerpo_b` anotados, no se puede persistir.
11//!
12//! Cada tipo vive en su propio `sled::Tree` para que los iter no se
13//! mezclen y para que `len()` por colección tenga sentido. El sled
14//! subyacente sigue siendo el mismo `Db` que abre [`crate::GraphStore`]
15//! — un solo path en disco contiene todo el estado de pluma.
16
17use sled::{Db, Tree};
18use uuid::Uuid;
19
20use serde::{Deserialize, Serialize};
21
22use pluma_align::CartaHebras;
23use pluma_cuerpo::Cuerpo;
24use pluma_transform::Transformacion;
25
26use crate::StoreError;
27
28const TREE_ATOMS: &str = "atoms";
29const TREE_CUERPOS: &str = "cuerpos";
30const TREE_TRANSFORMACIONES: &str = "transformaciones";
31const TREE_CARTAS: &str = "cartas";
32const TREE_UI: &str = "ui";
33const KEY_ESTADO_UI: &[u8] = b"default";
34
35/// Estado de UI persistible — sobrevive a cerrar la app. Por documento
36/// hay UN estado (clave fija `b"default"` en el tree "ui"); si el
37/// futuro pide multi-documento, las claves se versionan por documento.
38#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
39pub struct EstadoUi {
40    /// Focus mode: ocultar todos los cuerpos derivados, solo madre.
41    pub solo_madre: bool,
42    /// Búsqueda transversal activa (vacío = sin búsqueda).
43    pub busqueda: String,
44    /// Desplazamiento horizontal del multilienzo, en px.
45    pub scroll_x: f32,
46    /// Nombre del backend LLM activo — se restaura al reabrir el
47    /// documento. Formato libre (`"anthropic"`, `"gemini"`, …). Vacío
48    /// = el demo elige por defecto.
49    pub backend_llm: String,
50}
51
52/// Errores específicos del multilienzo store.
53#[derive(Debug, thiserror::Error)]
54pub enum MultilienzoError {
55    #[error("store: {0}")]
56    Store(#[from] StoreError),
57    #[error(
58        "carta sin par cuerpo_a/cuerpo_b anotado — no se puede persistir; \
59         use `CartaHebras::con_par(a, b)` al construirla"
60    )]
61    CartaSinPar,
62}
63
64/// Store unificado para todo el estado del multilienzo. Abre un único
65/// `sled::Db` y expone trees nominales para cada tipo.
66pub struct PlumaStore {
67    db: Db,
68    atoms: Tree,
69    cuerpos: Tree,
70    transformaciones: Tree,
71    cartas: Tree,
72    ui: Tree,
73}
74
75impl PlumaStore {
76    /// Abre (o crea) el store en `path`. Crea los cuatro trees nominales.
77    pub fn open(path: impl AsRef<std::path::Path>) -> Result<Self, MultilienzoError> {
78        let db = sled::open(path).map_err(StoreError::from)?;
79        let atoms = db.open_tree(TREE_ATOMS).map_err(StoreError::from)?;
80        let cuerpos = db.open_tree(TREE_CUERPOS).map_err(StoreError::from)?;
81        let transformaciones = db
82            .open_tree(TREE_TRANSFORMACIONES)
83            .map_err(StoreError::from)?;
84        let cartas = db.open_tree(TREE_CARTAS).map_err(StoreError::from)?;
85        let ui = db.open_tree(TREE_UI).map_err(StoreError::from)?;
86        Ok(Self {
87            db,
88            atoms,
89            cuerpos,
90            transformaciones,
91            cartas,
92            ui,
93        })
94    }
95
96    /// Vacía y persiste todos los cambios pendientes en disco. Útil para
97    /// confirmar batches antes de cerrar.
98    pub fn flush(&self) -> Result<(), MultilienzoError> {
99        self.db.flush().map_err(StoreError::from)?;
100        Ok(())
101    }
102
103    // ----- Atoms ----------------------------------------------------------
104
105    /// Guarda un `NarrativeAtom`. Sobrescribe si el id ya existía.
106    pub fn put_atom(&self, atom: &pluma_core::NarrativeAtom) -> Result<(), MultilienzoError> {
107        let bytes = bincode::serialize(atom).map_err(StoreError::from)?;
108        self.atoms
109            .insert(atom.id.as_bytes(), bytes)
110            .map_err(StoreError::from)?;
111        Ok(())
112    }
113
114    pub fn get_atom(&self, id: Uuid) -> Result<Option<pluma_core::NarrativeAtom>, MultilienzoError> {
115        match self.atoms.get(id.as_bytes()).map_err(StoreError::from)? {
116            Some(b) => Ok(Some(
117                bincode::deserialize(&b).map_err(StoreError::from)?,
118            )),
119            None => Ok(None),
120        }
121    }
122
123    /// Itera todos los átomos. El callback recibe `Result` porque la
124    /// deserialización puede fallar (datos corruptos) — el iter no se
125    /// detiene en el primer error; el caller decide.
126    pub fn iter_atoms(
127        &self,
128    ) -> impl Iterator<Item = Result<pluma_core::NarrativeAtom, MultilienzoError>> + '_ {
129        self.atoms.iter().map(|entry| {
130            let (_, bytes) = entry.map_err(|e| StoreError::from(e))?;
131            bincode::deserialize::<pluma_core::NarrativeAtom>(&bytes)
132                .map_err(|e| MultilienzoError::Store(StoreError::from(e)))
133        })
134    }
135
136    pub fn atoms_len(&self) -> usize {
137        self.atoms.len()
138    }
139
140    // ----- Cuerpos --------------------------------------------------------
141
142    pub fn put_cuerpo(&self, cuerpo: &Cuerpo) -> Result<(), MultilienzoError> {
143        let bytes = bincode::serialize(cuerpo).map_err(StoreError::from)?;
144        self.cuerpos
145            .insert(cuerpo.id.as_bytes(), bytes)
146            .map_err(StoreError::from)?;
147        Ok(())
148    }
149
150    pub fn get_cuerpo(&self, id: Uuid) -> Result<Option<Cuerpo>, MultilienzoError> {
151        match self.cuerpos.get(id.as_bytes()).map_err(StoreError::from)? {
152            Some(b) => Ok(Some(
153                bincode::deserialize(&b).map_err(StoreError::from)?,
154            )),
155            None => Ok(None),
156        }
157    }
158
159    pub fn iter_cuerpos(&self) -> impl Iterator<Item = Result<Cuerpo, MultilienzoError>> + '_ {
160        self.cuerpos.iter().map(|entry| {
161            let (_, bytes) = entry.map_err(|e| StoreError::from(e))?;
162            bincode::deserialize::<Cuerpo>(&bytes)
163                .map_err(|e| MultilienzoError::Store(StoreError::from(e)))
164        })
165    }
166
167    pub fn cuerpos_len(&self) -> usize {
168        self.cuerpos.len()
169    }
170
171    pub fn remove_cuerpo(&self, id: Uuid) -> Result<(), MultilienzoError> {
172        self.cuerpos
173            .remove(id.as_bytes())
174            .map_err(StoreError::from)?;
175        Ok(())
176    }
177
178    // ----- Transformaciones ----------------------------------------------
179
180    pub fn put_transformacion(&self, t: &Transformacion) -> Result<(), MultilienzoError> {
181        let bytes = bincode::serialize(t).map_err(StoreError::from)?;
182        self.transformaciones
183            .insert(t.id.as_bytes(), bytes)
184            .map_err(StoreError::from)?;
185        Ok(())
186    }
187
188    pub fn get_transformacion(
189        &self,
190        id: Uuid,
191    ) -> Result<Option<Transformacion>, MultilienzoError> {
192        match self
193            .transformaciones
194            .get(id.as_bytes())
195            .map_err(StoreError::from)?
196        {
197            Some(b) => Ok(Some(
198                bincode::deserialize(&b).map_err(StoreError::from)?,
199            )),
200            None => Ok(None),
201        }
202    }
203
204    pub fn iter_transformaciones(
205        &self,
206    ) -> impl Iterator<Item = Result<Transformacion, MultilienzoError>> + '_ {
207        self.transformaciones.iter().map(|entry| {
208            let (_, bytes) = entry.map_err(|e| StoreError::from(e))?;
209            bincode::deserialize::<Transformacion>(&bytes)
210                .map_err(|e| MultilienzoError::Store(StoreError::from(e)))
211        })
212    }
213
214    /// Lista las transformaciones cuyo `madre` es `madre_id`. Scan
215    /// lineal — sin índice secundario por ahora (el catálogo es chico
216    /// dentro de un documento).
217    pub fn transformaciones_de(
218        &self,
219        madre_id: Uuid,
220    ) -> Result<Vec<Transformacion>, MultilienzoError> {
221        let mut out = Vec::new();
222        for t in self.iter_transformaciones() {
223            let t = t?;
224            if t.madre == madre_id {
225                out.push(t);
226            }
227        }
228        Ok(out)
229    }
230
231    // ----- Cartas ---------------------------------------------------------
232
233    /// Persiste una carta. Exige que `carta.cuerpo_a` y `carta.cuerpo_b`
234    /// estén anotados — la clave de la carta es el par. Una carta huérfana
235    /// no puede persistirse: el dato del par ES la clave.
236    pub fn put_carta(&self, carta: &CartaHebras) -> Result<(), MultilienzoError> {
237        let key = clave_carta(carta)?;
238        let bytes = bincode::serialize(carta).map_err(StoreError::from)?;
239        self.cartas.insert(key, bytes).map_err(StoreError::from)?;
240        Ok(())
241    }
242
243    /// Lee la carta de un par. La búsqueda es por `(cuerpo_a, cuerpo_b)`
244    /// EN EL ORDEN dado — si la carta se guardó con orden invertido, no
245    /// la encuentra. Para encontrar bidireccionalmente, usar
246    /// [`Self::get_carta_bidir`].
247    pub fn get_carta(
248        &self,
249        cuerpo_a: Uuid,
250        cuerpo_b: Uuid,
251    ) -> Result<Option<CartaHebras>, MultilienzoError> {
252        let key = clave_de_par(cuerpo_a, cuerpo_b);
253        match self.cartas.get(&key).map_err(StoreError::from)? {
254            Some(b) => Ok(Some(
255                bincode::deserialize(&b).map_err(StoreError::from)?,
256            )),
257            None => Ok(None),
258        }
259    }
260
261    /// Busca la carta en cualquier orden de los cuerpos — primero
262    /// `(a,b)`, luego `(b,a)`. Útil cuando el caller no sabe en qué
263    /// orden fue construida originalmente.
264    pub fn get_carta_bidir(
265        &self,
266        c1: Uuid,
267        c2: Uuid,
268    ) -> Result<Option<CartaHebras>, MultilienzoError> {
269        if let Some(c) = self.get_carta(c1, c2)? {
270            return Ok(Some(c));
271        }
272        self.get_carta(c2, c1)
273    }
274
275    pub fn iter_cartas(
276        &self,
277    ) -> impl Iterator<Item = Result<CartaHebras, MultilienzoError>> + '_ {
278        self.cartas.iter().map(|entry| {
279            let (_, bytes) = entry.map_err(|e| StoreError::from(e))?;
280            bincode::deserialize::<CartaHebras>(&bytes)
281                .map_err(|e| MultilienzoError::Store(StoreError::from(e)))
282        })
283    }
284
285    pub fn cartas_len(&self) -> usize {
286        self.cartas.len()
287    }
288
289    // ----- Estado UI ------------------------------------------------------
290
291    /// Guarda el estado de UI del documento. Sobrescribe el anterior; el
292    /// modelo aquí asume UN estado por store (clave fija).
293    pub fn put_estado_ui(&self, e: &EstadoUi) -> Result<(), MultilienzoError> {
294        let bytes = bincode::serialize(e).map_err(StoreError::from)?;
295        self.ui.insert(KEY_ESTADO_UI, bytes).map_err(StoreError::from)?;
296        Ok(())
297    }
298
299    /// Lee el estado de UI persistido. `None` si nunca se guardó —
300    /// caller cae a `EstadoUi::default()`.
301    pub fn get_estado_ui(&self) -> Result<Option<EstadoUi>, MultilienzoError> {
302        match self.ui.get(KEY_ESTADO_UI).map_err(StoreError::from)? {
303            Some(b) => Ok(Some(
304                bincode::deserialize(&b).map_err(StoreError::from)?,
305            )),
306            None => Ok(None),
307        }
308    }
309}
310
311/// Compone la clave binaria de una carta: 16 bytes de `cuerpo_a` +
312/// 16 bytes de `cuerpo_b`. Devuelve error si la carta no tiene par.
313fn clave_carta(carta: &CartaHebras) -> Result<[u8; 32], MultilienzoError> {
314    match (carta.cuerpo_a, carta.cuerpo_b) {
315        (Some(a), Some(b)) => Ok(clave_de_par(a, b)),
316        _ => Err(MultilienzoError::CartaSinPar),
317    }
318}
319
320/// Compone la clave binaria de un par `(cuerpo_a, cuerpo_b)`. El orden
321/// importa — distintos órdenes = distintas claves.
322fn clave_de_par(a: Uuid, b: Uuid) -> [u8; 32] {
323    let mut k = [0u8; 32];
324    k[0..16].copy_from_slice(a.as_bytes());
325    k[16..32].copy_from_slice(b.as_bytes());
326    k
327}
328
329#[cfg(test)]
330mod pruebas {
331    use super::*;
332    use pluma_align::{Alineamiento, CartaHebras, OrigenAlineamiento};
333    use pluma_core::NarrativeAtom;
334    use pluma_cuerpo::{Cuerpo, Intencion};
335    use pluma_transform::{TipoTransformacion, Transformacion};
336
337    fn store_temp() -> (PlumaStore, tempfile::TempDir) {
338        let dir = tempfile::tempdir().unwrap();
339        let s = PlumaStore::open(dir.path().join("pluma.sled")).unwrap();
340        (s, dir)
341    }
342
343    #[test]
344    fn atom_roundtrip_y_count() {
345        let (s, _d) = store_temp();
346        let a = NarrativeAtom::new("hola", "es");
347        let id = a.id;
348        s.put_atom(&a).unwrap();
349        let cargado = s.get_atom(id).unwrap().unwrap();
350        assert_eq!(*cargado.content, *a.content);
351        assert_eq!(s.atoms_len(), 1);
352    }
353
354    #[test]
355    fn cuerpo_roundtrip_conserva_metadatos_y_orden() {
356        let (s, _d) = store_temp();
357        let mut c = Cuerpo::nuevo("es", "es (original)", Intencion::Original, 100);
358        c.agregar(Uuid::new_v4(), 101);
359        c.agregar(Uuid::new_v4(), 102);
360        let id = c.id;
361        s.put_cuerpo(&c).unwrap();
362
363        let cargado = s.get_cuerpo(id).unwrap().unwrap();
364        assert_eq!(cargado, c);
365        assert_eq!(s.cuerpos_len(), 1);
366    }
367
368    #[test]
369    fn cuerpo_remove_devuelve_none() {
370        let (s, _d) = store_temp();
371        let c = Cuerpo::nuevo("es", "es", Intencion::Original, 0);
372        let id = c.id;
373        s.put_cuerpo(&c).unwrap();
374        s.remove_cuerpo(id).unwrap();
375        assert!(s.get_cuerpo(id).unwrap().is_none());
376    }
377
378    #[test]
379    fn transformacion_roundtrip_y_filtro_por_madre() {
380        let (s, _d) = store_temp();
381        let madre = Uuid::new_v4();
382        let otra_madre = Uuid::new_v4();
383        let t1 = Transformacion::nueva(
384            madre,
385            Uuid::new_v4(),
386            TipoTransformacion::Traducir { lengua_destino: "qu".into() },
387            "ana",
388            1,
389        );
390        let t2 = Transformacion::nueva(
391            madre,
392            Uuid::new_v4(),
393            TipoTransformacion::Tono { etiqueta: "formal".into() },
394            "ana",
395            2,
396        );
397        let t3 = Transformacion::nueva(
398            otra_madre,
399            Uuid::new_v4(),
400            TipoTransformacion::Identidad,
401            "ana",
402            3,
403        );
404        s.put_transformacion(&t1).unwrap();
405        s.put_transformacion(&t2).unwrap();
406        s.put_transformacion(&t3).unwrap();
407
408        let de_madre = s.transformaciones_de(madre).unwrap();
409        assert_eq!(de_madre.len(), 2);
410        let ids: Vec<Uuid> = de_madre.iter().map(|t| t.id).collect();
411        assert!(ids.contains(&t1.id));
412        assert!(ids.contains(&t2.id));
413        assert!(!ids.contains(&t3.id));
414    }
415
416    #[test]
417    fn carta_sin_par_no_persiste() {
418        let (s, _d) = store_temp();
419        let carta = CartaHebras::nueva(); // sin con_par(...)
420        match s.put_carta(&carta) {
421            Err(MultilienzoError::CartaSinPar) => {}
422            otro => panic!("esperaba CartaSinPar, fue {otro:?}"),
423        }
424    }
425
426    #[test]
427    fn carta_con_par_roundtrip_por_clave_y_bidir() {
428        let (s, _d) = store_temp();
429        let a = Uuid::new_v4();
430        let b = Uuid::new_v4();
431        let mut carta = CartaHebras::nueva().con_par(a, b);
432        carta.agregar(Alineamiento::nuevo(
433            Uuid::new_v4(),
434            Uuid::new_v4(),
435            0.8,
436            OrigenAlineamiento::Manual { autor: "x".into(), timestamp: 1 },
437        ));
438        s.put_carta(&carta).unwrap();
439
440        // Por orden directo: la encuentra.
441        let r = s.get_carta(a, b).unwrap().unwrap();
442        assert_eq!(r.hebras.len(), 1);
443
444        // Por orden invertido (sin bidir): no la encuentra.
445        assert!(s.get_carta(b, a).unwrap().is_none());
446
447        // Con bidir: la encuentra.
448        assert!(s.get_carta_bidir(b, a).unwrap().is_some());
449    }
450
451    #[test]
452    fn iter_recupera_todos() {
453        let (s, _d) = store_temp();
454        for i in 0..5 {
455            let c = Cuerpo::nuevo("x", format!("c{i}"), Intencion::Original, i as u64);
456            s.put_cuerpo(&c).unwrap();
457        }
458        let todos: Vec<Cuerpo> = s.iter_cuerpos().collect::<Result<_, _>>().unwrap();
459        assert_eq!(todos.len(), 5);
460    }
461
462    #[test]
463    fn estado_ui_roundtrip_y_default_si_vacio() {
464        let (s, _d) = store_temp();
465        // Sin nada guardado, get devuelve None.
466        assert!(s.get_estado_ui().unwrap().is_none());
467        // Put + get devuelve el estado intacto.
468        let e = EstadoUi {
469            solo_madre: true,
470            busqueda: "cóndor".to_string(),
471            scroll_x: 240.5,
472            backend_llm: "gemini".to_string(),
473        };
474        s.put_estado_ui(&e).unwrap();
475        let r = s.get_estado_ui().unwrap().unwrap();
476        assert_eq!(r, e);
477    }
478
479    #[test]
480    fn flush_no_pierde_dato_tras_reabrir() {
481        let dir = tempfile::tempdir().unwrap();
482        let path = dir.path().join("pluma.sled");
483        let id = {
484            let s = PlumaStore::open(&path).unwrap();
485            let c = Cuerpo::nuevo("es", "es", Intencion::Original, 0);
486            let id = c.id;
487            s.put_cuerpo(&c).unwrap();
488            s.flush().unwrap();
489            id
490        };
491        // Reabrir el mismo path.
492        let s2 = PlumaStore::open(&path).unwrap();
493        assert!(s2.get_cuerpo(id).unwrap().is_some());
494        assert_eq!(s2.cuerpos_len(), 1);
495    }
496}