⎇ 30467600bcd9effa15e8338fb32d357c1edab660
00_unanchay/pluma/pluma-store/src/multilienzo.rs
History
Who touched what
raw
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}