tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
1use super::*;
2
3#[cfg(test)]
4mod bloque25_harness {
5 //! Arnés del Bloque 25 (memo, enfoque A): genera árboles con nodos `memo`,
6 //! los muta, y verifica que el camino memoizado (`take_memo_builders` + mount
7 //! + compute + `splice_memos`) da **exactamente el mismo `Mounted`** (rects,
8 //! nodo a nodo en pre-orden) que el camino fresco (memo expandido inline).
9 //! Como los memos NO tocados no re-buildean, también ejercita el reuso.
10 use super::*;
11 use crate::{mount, View};
12 use llimphi_layout::taffy::prelude::*;
13 use llimphi_layout::LayoutTree;
14 use core::hash::{Hash, Hasher};
15
16 struct Rng(u64);
17 impl Rng {
18 fn next(&mut self) -> u64 {
19 let mut x = self.0;
20 x ^= x << 13;
21 x ^= x >> 7;
22 x ^= x << 17;
23 self.0 = x;
24 x
25 }
26 fn range(&mut self, n: usize) -> usize {
27 (self.next() % (n as u64).max(1)) as usize
28 }
29 }
30
31 #[derive(Clone)]
32 enum Spec {
33 Leaf { w: f32, h: f32, text: Option<String> },
34 Row(Vec<Spec>),
35 Col(Vec<Spec>),
36 /// Nodo memo de tamaño definido `w×h` envolviendo `content` (sin memos
37 /// anidados en v1). `deps` se deriva de `content` en `build`.
38 Memo { key: u64, w: f32, h: f32, content: Box<Spec> },
39 }
40
41 fn spec_hash(s: &Spec) -> u64 {
42 let mut h = hasher_memo();
43 fn go(s: &Spec, h: &mut impl Hasher) {
44 match s {
45 Spec::Leaf { w, h: hh, text } => {
46 0u8.hash(h);
47 w.to_bits().hash(h);
48 hh.to_bits().hash(h);
49 text.hash(h);
50 }
51 Spec::Row(k) => {
52 1u8.hash(h);
53 for c in k {
54 go(c, h);
55 }
56 }
57 Spec::Col(k) => {
58 2u8.hash(h);
59 for c in k {
60 go(c, h);
61 }
62 }
63 Spec::Memo { key, w, h: hh, content } => {
64 3u8.hash(h);
65 key.hash(h);
66 w.to_bits().hash(h);
67 hh.to_bits().hash(h);
68 go(content, h);
69 }
70 }
71 }
72 go(s, &mut h);
73 h.finish()
74 }
75
76 fn gen(rng: &mut Rng, depth: usize, allow_memo: bool, next_key: &mut u64) -> Spec {
77 if depth == 0 || rng.range(100) < 35 {
78 let text = if rng.range(2) == 0 {
79 Some(format!("n{}", rng.range(1000)))
80 } else {
81 None
82 };
83 Spec::Leaf {
84 w: 20.0 + rng.range(60) as f32,
85 h: 12.0 + rng.range(30) as f32,
86 text,
87 }
88 } else if allow_memo && rng.range(100) < 35 {
89 let content = gen(rng, depth - 1, false, next_key);
90 let key = *next_key;
91 *next_key += 1;
92 Spec::Memo {
93 key,
94 w: 120.0 + rng.range(120) as f32,
95 h: 120.0 + rng.range(120) as f32,
96 content: Box::new(content),
97 }
98 } else {
99 let n = 1 + rng.range(3);
100 let kids = (0..n).map(|_| gen(rng, depth - 1, allow_memo, next_key)).collect();
101 if rng.range(2) == 0 {
102 Spec::Row(kids)
103 } else {
104 Spec::Col(kids)
105 }
106 }
107 }
108
109 /// Muta manteniendo la forma (cambia valores; a veces el contenido de un memo
110 /// → su `deps` cambia → rebuild; a veces no → reuso).
111 fn mutate(s: &mut Spec, rng: &mut Rng) {
112 match s {
113 Spec::Leaf { w, h, text } => match rng.range(4) {
114 0 => *w = 20.0 + rng.range(60) as f32,
115 1 => *h = 12.0 + rng.range(30) as f32,
116 2 => *text = Some(format!("m{}", rng.range(1000))),
117 _ => {}
118 },
119 Spec::Row(k) | Spec::Col(k) => {
120 for c in k {
121 mutate(c, rng);
122 }
123 }
124 Spec::Memo { content, .. } => {
125 // 50% muta el contenido (→ rebuild), 50% lo deja (→ reuso).
126 if rng.range(2) == 0 {
127 mutate(content, rng);
128 }
129 }
130 }
131 }
132
133 fn build(s: &Spec, memoize: bool) -> View<()> {
134 match s {
135 Spec::Leaf { w, h, text } => {
136 let st = Style {
137 size: Size {
138 width: length(*w),
139 height: if text.is_some() { auto() } else { length(*h) },
140 },
141 flex_shrink: 0.0,
142 ..Default::default()
143 };
144 let v = View::<()>::new(st);
145 match text {
146 Some(t) => v.text(t.clone(), 14.0, Color::WHITE),
147 None => v,
148 }
149 }
150 Spec::Row(k) | Spec::Col(k) => {
151 let row = matches!(s, Spec::Row(_));
152 View::<()>::new(Style {
153 flex_direction: if row {
154 FlexDirection::Row
155 } else {
156 FlexDirection::Column
157 },
158 ..Default::default()
159 })
160 .children(k.iter().map(|c| build(c, memoize)).collect())
161 }
162 Spec::Memo { key, w, h, content } => {
163 let st = Style {
164 size: Size { width: length(*w), height: length(*h) },
165 flex_shrink: 0.0,
166 ..Default::default()
167 };
168 if memoize {
169 let c = (**content).clone();
170 let deps = spec_hash(content);
171 View::<()>::new(st).memo(*key, deps, move || {
172 MEMO_BUILDS.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
173 build(&c, true)
174 })
175 } else {
176 View::<()>::new(st).children(vec![build(content, false)])
177 }
178 }
179 }
180 }
181
182 fn fake_measure(
183 tm: &TextMeasure,
184 known: Size<Option<f32>>,
185 avail: Size<AvailableSpace>,
186 ) -> Size<f32> {
187 let chars = tm.content.chars().count().max(1) as f32;
188 let gw = tm.size_px * 0.5;
189 let ideal = chars * gw;
190 let maxw = match known.width {
191 Some(w) => w,
192 None => match avail.width {
193 AvailableSpace::Definite(w) => w,
194 AvailableSpace::MaxContent => f32::INFINITY,
195 AvailableSpace::MinContent => gw,
196 },
197 };
198 let w = ideal.min(maxw).max(gw);
199 let lines = if w > 0.0 { (ideal / w).ceil().max(1.0) } else { 1.0 };
200 Size {
201 width: w,
202 height: lines * tm.size_px * tm.line_height.max(1.0),
203 }
204 }
205
206 static MEMO_BUILDS: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0);
207
208 #[test]
209 fn memo_igual_a_fresco_fuzz() {
210 use core::sync::atomic::Ordering;
211 MEMO_BUILDS.store(0, Ordering::Relaxed);
212 let mut opportunities = 0u64;
213 for seed in 0..40u64 {
214 opportunities += run_seed(0xD1B5_4A32_D192_ED03 ^ seed.wrapping_mul(0x100_0193));
215 }
216 let builds = MEMO_BUILDS.load(Ordering::Relaxed);
217 // Correctitud ya asertada dentro de run_seed. Aquí probamos el REUSO: el
218 // builder se llamó MENOS veces que oportunidades (markers·frames) → hubo
219 // memos reusados sin reconstruir; y >0 → los cambios sí rebuildean.
220 assert!(builds > 0, "algún memo debe rebuildear (builds={builds})");
221 assert!(
222 builds < opportunities,
223 "el reuso debe ocurrir (builds={builds} de {opportunities} oportunidades)"
224 );
225 println!("[bloque25] seeds=40 builds={builds}/{opportunities} oportunidades (resto reusado); rects == fresco (±1px)");
226 }
227
228 /// Devuelve la cantidad de oportunidades de memo (Σ markers por frame).
229 fn run_seed(seed: u64) -> u64 {
230 let mut rng = Rng(seed | 1);
231 let mut next_key = 1u64;
232 let mut spec = gen(&mut rng, 5, true, &mut next_key);
233 let mut reg: MemoRegistry<()> = MemoRegistry::new();
234 let vp = (900.0, 700.0);
235 let mut opportunities = 0u64;
236
237 for frame in 0..60 {
238 if frame > 0 {
239 mutate(&mut spec, &mut rng);
240 }
241 // Fresco: memos expandidos inline.
242 let mut ft = LayoutTree::new();
243 let mf = mount(&mut ft, build(&spec, false));
244 let cf = {
245 let tm = &mf.text_measures;
246 ft.compute_with_measure(mf.root, vp, |nid, k, a| match tm.get(&nid) {
247 Some(t) => fake_measure(t, k, a),
248 None => Size { width: 0.0, height: 0.0 },
249 })
250 .unwrap()
251 };
252 // Memoizado.
253 let mut vm = build(&spec, true);
254 let mut pending = Vec::new();
255 take_memo_builders(&mut vm, &mut pending);
256 opportunities += pending.len() as u64;
257 let mut mt = LayoutTree::new();
258 let mut mm = mount(&mut mt, vm);
259 let mut cm = {
260 let tm = &mm.text_measures;
261 mt.compute_with_measure(mm.root, vp, |nid, k, a| match tm.get(&nid) {
262 Some(t) => fake_measure(t, k, a),
263 None => Size { width: 0.0, height: 0.0 },
264 })
265 .unwrap()
266 };
267 splice_memos(&mut mm, &mut cm, &pending, &mut reg, fake_measure);
268
269 // Tolerancia 1px: taffy redondea el layout a píxel de forma
270 // DEPENDIENTE de la posición absoluta (alinea bordes a la grilla). El
271 // subárbol aislado se computa en base-0 y se offsetea, así que su
272 // redondeo puede diferir del fresco (computado en la posición real)
273 // por ≤1px en tamaños. Es acotado, invisible, y CONSISTENTE en una
274 // app real (siempre se usa el camino memo, nunca se alterna con el
275 // fresco → sin jitter). La ESTRUCTURA y las posiciones coinciden.
276 let tol = 1.01_f32;
277 let mut mismatch = mf.nodes.len() != mm.nodes.len();
278 if !mismatch {
279 for i in 0..mf.nodes.len() {
280 let a = cf.get(mf.nodes[i].id).unwrap();
281 let b = cm.get(mm.nodes[i].id).unwrap();
282 if (a.x - b.x).abs() > tol
283 || (a.y - b.y).abs() > tol
284 || (a.w - b.w).abs() > tol
285 || (a.h - b.h).abs() > tol
286 {
287 mismatch = true;
288 break;
289 }
290 }
291 }
292 if mismatch && std::env::var("MEMO_DEBUG").is_ok() {
293 eprintln!("### seed={seed:#x} frame={frame} ###");
294 eprintln!("--- FRESCO ({} nodos) ---", mf.nodes.len());
295 for (i, n) in mf.nodes.iter().enumerate() {
296 eprintln!(" {i}: memo_ref={:?} end={} rect={:?}", n.memo_ref, n.subtree_end, cf.get(n.id));
297 }
298 eprintln!("--- MEMO ({} nodos) ---", mm.nodes.len());
299 for (i, n) in mm.nodes.iter().enumerate() {
300 eprintln!(" {i}: memo_ref={:?} end={} rect={:?}", n.memo_ref, n.subtree_end, cm.get(n.id));
301 }
302 panic!("dump");
303 }
304 assert!(!mismatch, "seed {seed:#x} frame {frame}: memo != fresco");
305 }
306 opportunities
307 }
308}
309
310#[cfg(test)]
311mod bloque24_harness {
312 //! Arnés de equivalencia del Bloque 24: genera árboles al azar, los muta
313 //! (estilo, texto, estructura) y verifica que `mount_incremental` (retenido)
314 //! da **exactamente los mismos rects** que `mount` fresco, frame a frame.
315 //! Es la red de seguridad ANTES de cablear el layout retenido a la ruta
316 //! caliente. La medida es falsa pero DETERMINISTA y depende del texto, así
317 //! que si el reuso no marcara dirty un texto cambiado, los rects diferirían
318 //! y el test fallaría.
319 use super::*;
320 use crate::{mount, View};
321 use llimphi_layout::taffy::prelude::*;
322 use llimphi_layout::LayoutTree;
323
324 struct Rng(u64);
325 impl Rng {
326 fn next(&mut self) -> u64 {
327 let mut x = self.0;
328 x ^= x << 13;
329 x ^= x >> 7;
330 x ^= x << 17;
331 self.0 = x;
332 x
333 }
334 fn range(&mut self, n: usize) -> usize {
335 (self.next() % (n as u64).max(1)) as usize
336 }
337 }
338
339 #[derive(Clone)]
340 enum Spec {
341 Leaf { w: f32, h: f32, text: Option<String> },
342 Row(Vec<Spec>),
343 Col(Vec<Spec>),
344 }
345
346 fn gen_spec(rng: &mut Rng, depth: usize) -> Spec {
347 if depth == 0 || rng.range(100) < 40 {
348 let text = if rng.range(2) == 0 {
349 Some(format!("n{}", rng.range(1000)))
350 } else {
351 None
352 };
353 Spec::Leaf {
354 w: 20.0 + rng.range(80) as f32,
355 h: 12.0 + rng.range(40) as f32,
356 text,
357 }
358 } else {
359 let n = 1 + rng.range(4);
360 let kids = (0..n).map(|_| gen_spec(rng, depth - 1)).collect();
361 if rng.range(2) == 0 {
362 Spec::Row(kids)
363 } else {
364 Spec::Col(kids)
365 }
366 }
367 }
368
369 /// Muta manteniendo la FORMA (child-counts) → ejercita el camino de reuso.
370 fn mutate_style(s: &mut Spec, rng: &mut Rng) {
371 match s {
372 Spec::Leaf { w, h, text } => match rng.range(4) {
373 0 => *w = 20.0 + rng.range(80) as f32,
374 1 => *h = 12.0 + rng.range(40) as f32,
375 2 => *text = Some(format!("m{}", rng.range(1000))),
376 _ => {}
377 },
378 Spec::Row(k) | Spec::Col(k) => {
379 for c in k {
380 mutate_style(c, rng);
381 }
382 }
383 }
384 }
385
386 /// Cambia la FORMA (agrega/quita hijos) → fuerza el camino fresco.
387 fn mutate_structure(s: &mut Spec, rng: &mut Rng) {
388 match s {
389 Spec::Leaf { .. } => *s = gen_spec(rng, 3),
390 Spec::Row(k) | Spec::Col(k) => {
391 if k.len() > 1 && rng.range(2) == 0 {
392 let i = rng.range(k.len());
393 k.remove(i);
394 } else {
395 let i = rng.range(k.len() + 1);
396 k.insert(i, gen_spec(rng, 2));
397 }
398 }
399 }
400 }
401
402 fn build(s: &Spec) -> View<()> {
403 match s {
404 Spec::Leaf { w, h, text } => {
405 let st = Style {
406 size: Size {
407 width: length(*w),
408 height: if text.is_some() { auto() } else { length(*h) },
409 },
410 flex_shrink: 0.0,
411 ..Default::default()
412 };
413 let v = View::<()>::new(st);
414 match text {
415 Some(t) => v.text(t.clone(), 14.0, Color::WHITE),
416 None => v,
417 }
418 }
419 Spec::Row(k) | Spec::Col(k) => {
420 let row = matches!(s, Spec::Row(_));
421 View::<()>::new(Style {
422 flex_direction: if row {
423 FlexDirection::Row
424 } else {
425 FlexDirection::Column
426 },
427 ..Default::default()
428 })
429 .children(k.iter().map(build).collect())
430 }
431 }
432 }
433
434 /// Medida FALSA pero determinista: ancho ∝ nº de chars, alto por wrapping.
435 /// Depende del contenido → un cambio de texto altera la medida.
436 fn fake_measure(
437 tmap: &hashbrown::HashMap<NodeId, TextMeasure>,
438 nid: NodeId,
439 known: Size<Option<f32>>,
440 avail: Size<AvailableSpace>,
441 ) -> Size<f32> {
442 let Some(tm) = tmap.get(&nid) else {
443 return Size { width: 0.0, height: 0.0 };
444 };
445 let chars = tm.content.chars().count().max(1) as f32;
446 let gw = tm.size_px * 0.5;
447 let ideal = chars * gw;
448 let maxw = match known.width {
449 Some(w) => w,
450 None => match avail.width {
451 AvailableSpace::Definite(w) => w,
452 AvailableSpace::MaxContent => f32::INFINITY,
453 AvailableSpace::MinContent => gw,
454 },
455 };
456 let w = ideal.min(maxw).max(gw);
457 let lines = if w > 0.0 { (ideal / w).ceil().max(1.0) } else { 1.0 };
458 Size {
459 width: w,
460 height: lines * tm.size_px * tm.line_height.max(1.0),
461 }
462 }
463
464 #[test]
465 fn retenido_igual_a_fresco_fuzz() {
466 // Muchas semillas → árboles y secuencias de mutación variados.
467 let mut tot_reused = 0u32;
468 let mut tot_fresh = 0u32;
469 for seed in 0..40u64 {
470 let (r, f) = run_seed(0x9E37_79B9_7F4A_7C15 ^ seed.wrapping_mul(0x1000_0001B3));
471 tot_reused += r;
472 tot_fresh += f;
473 }
474 assert!(tot_reused > 30 * 40, "el reuso debe dominar (reused={tot_reused})");
475 assert!(tot_fresh > 5 * 40, "y el rebuild también (fresh={tot_fresh})");
476 println!("[bloque24] seeds=40 reused={tot_reused} fresh={tot_fresh} (todos los rects == fresco)");
477 }
478
479 fn run_seed(seed: u64) -> (u32, u32) {
480 let mut rng = Rng(seed | 1);
481 let mut spec = gen_spec(&mut rng, 4);
482 let mut ret_tree = LayoutTree::new();
483 let mut prev: Option<RetainedLayout> = None;
484 let mut reused = 0;
485 let mut fresh = 0;
486
487 for frame in 0..80 {
488 if frame > 0 {
489 if frame % 6 == 0 {
490 mutate_structure(&mut spec, &mut rng);
491 } else {
492 mutate_style(&mut spec, &mut rng);
493 }
494 }
495 // Viewport variable: prueba que el reuso recoge cambios de
496 // available-space (resize) aunque no marque dirty (taffy re-resuelve
497 // por cambio de espacio disponible).
498 let vp = match frame % 4 {
499 0 => (800.0, 600.0),
500 1 => (640.0, 600.0),
501 2 => (900.0, 500.0),
502 _ => (720.0, 680.0),
503 };
504
505 // Camino fresco de referencia.
506 let mut fresh_tree = LayoutTree::new();
507 let mf = mount(&mut fresh_tree, build(&spec));
508 let cf = {
509 let tf = &mf.text_measures;
510 fresh_tree
511 .compute_with_measure(mf.root, vp, |nid, k, a| fake_measure(tf, nid, k, a))
512 .unwrap()
513 };
514
515 // Camino retenido.
516 let (mr, retained, did_reuse) =
517 mount_incremental(&mut ret_tree, build(&spec), prev.as_ref());
518 let cr = {
519 let tr = &mr.text_measures;
520 ret_tree
521 .compute_with_measure(mr.root, vp, |nid, k, a| fake_measure(tr, nid, k, a))
522 .unwrap()
523 };
524 if did_reuse {
525 reused += 1;
526 } else {
527 fresh += 1;
528 }
529
530 assert_eq!(mf.nodes.len(), mr.nodes.len(), "frame {frame}: nº de nodos");
531 for i in 0..mf.nodes.len() {
532 let a = cf.get(mf.nodes[i].id).expect("rect fresco");
533 let b = cr.get(mr.nodes[i].id).expect("rect retenido");
534 assert!(
535 (a.x - b.x).abs() < 1e-3
536 && (a.y - b.y).abs() < 1e-3
537 && (a.w - b.w).abs() < 1e-3
538 && (a.h - b.h).abs() < 1e-3,
539 "frame {frame} nodo {i} reuse={did_reuse}: fresco {a:?} != retenido {b:?}"
540 );
541 }
542 prev = Some(retained);
543 }
544 (reused, fresh)
545 }
546}
547