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
1//! El **registro de uso** de los fantasmas: cuántas veces se clickeó cada uno.
2//!
3//! Es la mitad del asiento aprendido que toca el disco, y por eso vive tras la
4//! feature `persistencia`: sin ella el crate es `no_std` puro y el uso queda en
5//! memoria (manda el prior), que es lo que necesita el kernel de wawa.
6//!
7//! **La ruta la elige el consumidor.** Este crate no sabe cómo se llama la app
8//! ni dónde guarda sus cosas; recibe un `PathBuf` y se ocupa de que escribir no
9//! pueda dejar el archivo a medias.
10
11extern crate std;
12
13use alloc::string::{String, ToString};
14use std::collections::HashMap;
15use std::path::PathBuf;
16
17use crate::Fantasma;
18
19/// Cuántos clicks lleva cada fantasma, por [`Fantasma::clave`].
20///
21/// Nunca falla: sin archivo, sin permiso o con JSON roto arranca de cero y el
22/// prior manda. Un contador de comodidad no puede ser motivo de que la app no
23/// levante.
24#[derive(Default, Debug)]
25pub struct Uso {
26 usos: HashMap<String, u32>,
27 path: Option<PathBuf>,
28}
29
30impl Uso {
31 /// Abre (o crea vacío) el registro en `path`. `None` = el uso vive en
32 /// memoria y se pierde al cerrar.
33 pub fn abrir(path: Option<PathBuf>) -> Self {
34 let usos = path
35 .as_ref()
36 .and_then(|p| std::fs::read(p).ok())
37 .and_then(|b| serde_json::from_slice(&b).ok())
38 .unwrap_or_default();
39 Self { usos, path }
40 }
41
42 /// Cuántos clicks lleva `f`.
43 pub fn usos<F: Fantasma>(&self, f: F) -> u32 {
44 self.usos_de(f.clave())
45 }
46
47 /// Registra un uso (click) de `f` y persiste.
48 pub fn bump<F: Fantasma>(&mut self, f: F) {
49 self.bump_de(f.clave());
50 }
51
52 /// Puntaje de asiento de `f` — [`crate::score`] con el uso de este registro.
53 pub fn score<F: Fantasma>(&self, f: F) -> f32 {
54 crate::score(f, self.usos(f))
55 }
56
57 // ─── La misma cuenta, con la clave CRUDA ────────────────────────────────
58 //
59 // Un [`Fantasma`] tiene clave `&'static str` porque su población es un enum
60 // que se escribe a mano. Hay poblaciones que no: las **categorías de
61 // aplicaciones** de pata salen del catálogo de `.desktop` de la máquina, así
62 // que su clave es un `String` que nadie puede declarar de antemano.
63 //
64 // Es la misma pregunta («¿cuánto usás esto?»), el mismo archivo y la misma
65 // curva, así que se generaliza el registro en vez de fabricarle un gemelo al
66 // lado (Regla 10 del repo, y lo que pH7.b dejó anotado: *«lo que falta no es
67 // el mecanismo: es generalizarlo para que la clave no sea sólo un `Fugaz`»*).
68
69 /// Cuántos usos lleva la clave `k`.
70 pub fn usos_de(&self, k: &str) -> u32 {
71 self.usos.get(k).copied().unwrap_or(0)
72 }
73
74 /// Registra un uso de la clave `k` y persiste.
75 pub fn bump_de(&mut self, k: &str) {
76 *self.usos.entry(k.to_string()).or_insert(0) += 1;
77 self.persist();
78 }
79
80 /// Puntaje de la clave `k` con el `prior` que le dé el consumidor: la misma
81 /// curva que [`crate::score`] (`prior + 2·√usos`, que satura), para que dos
82 /// poblaciones distintas no aprendan a ritmos distintos.
83 pub fn score_de(&self, k: &str, prior: f32) -> f32 {
84 prior + 2.0 * (self.usos_de(k) as f32).sqrt()
85 }
86
87 /// Suma de todos los usos registrados. Es el reloj con que un consumidor
88 /// decide cuándo [`Uso::envejecer`]: no hay fechas en el archivo, y meterlas
89 /// haría que dos máquinas con relojes distintos aprendieran distinto.
90 pub fn total(&self) -> u32 {
91 self.usos.values().sum()
92 }
93
94 /// **Envejecer**: divide a la mitad todos los conteos y suelta los que
95 /// llegan a cero.
96 ///
97 /// Es la diferencia entre «lo que más usaste **alguna vez**» y «lo que más
98 /// usás **últimamente**». Un contador que sólo sube nunca olvida: algo que
99 /// usaste veinte veces el mes pasado le gana para siempre a lo que usás hoy,
100 /// y el asiento deja de moverse. Halvar es el olvido exponencial de siempre
101 /// (el de los cachés LFU): barato, sin fechas, y conserva el ORDEN entre los
102 /// que sobreviven — lo que cambia es cuánto cuesta pasar al de adelante.
103 ///
104 /// No lo llama el registro solo: **el consumidor decide cada cuánto**, con
105 /// [`Uso::total`] como reloj. Los fantasmas no lo usan (su población es fija
106 /// y su prior manda); las categorías de aplicaciones sí.
107 pub fn envejecer(&mut self) {
108 self.usos.values_mut().for_each(|v| *v /= 2);
109 self.usos.retain(|_, v| *v > 0);
110 self.persist();
111 }
112
113 /// Escritura atómica: se escribe a `.tmp` y se renombra. Un corte de luz a
114 /// mitad deja el archivo viejo entero, no uno truncado que después no
115 /// parsea (y que sería indistinguible de «nunca usaste nada»).
116 fn persist(&self) {
117 let Some(path) = &self.path else { return };
118 let Ok(bytes) = serde_json::to_vec_pretty(&self.usos) else { return };
119 if let Some(dir) = path.parent() {
120 let _ = std::fs::create_dir_all(dir);
121 }
122 let tmp = path.with_extension("json.tmp");
123 if std::fs::write(&tmp, &bytes).is_ok() {
124 let _ = std::fs::rename(&tmp, path);
125 }
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use alloc::format;
133
134 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
135 enum T {
136 Uno,
137 Dos,
138 }
139
140 impl Fantasma for T {
141 fn clave(self) -> &'static str {
142 match self {
143 T::Uno => "uno",
144 T::Dos => "dos",
145 }
146 }
147 fn prior(self) -> f32 {
148 match self {
149 T::Uno => 1.0,
150 T::Dos => 2.0,
151 }
152 }
153 }
154
155 /// Un archivo propio por test: corren en paralelo dentro del mismo proceso
156 /// y compartir la ruta haría que se pisen los conteos entre sí.
157 fn ruta(nombre: &str) -> PathBuf {
158 std::env::temp_dir()
159 .join(format!("fugaces-uso-{}-{nombre}", std::process::id()))
160 .join("uso.json")
161 }
162
163 /// **La mitad que toca el disco.** Que el asiento se aprenda no sirve de
164 /// nada si se olvida al cerrar la app — y esa mitad no la cubre ningún test
165 /// de la máquina, porque la máquina no sabe que el disco existe.
166 #[test]
167 fn el_asiento_aprendido_sobrevive_a_cerrar_la_app() {
168 let p = ruta("sobrevive");
169 let _ = std::fs::remove_dir_all(p.parent().unwrap());
170 let mut u = Uso::abrir(Some(p.clone()));
171 u.bump(T::Uno);
172 u.bump(T::Uno);
173 u.bump(T::Dos);
174 drop(u); // cerrar la app
175 let u = Uso::abrir(Some(p.clone()));
176 assert_eq!(u.usos(T::Uno), 2, "el conteo no volvió del disco");
177 assert_eq!(u.usos(T::Dos), 1);
178 // Y el uso mueve el asiento, pero **despacio**: el puntaje es
179 // `prior + 2·√usos`, o sea que satura. Dos clicks sobre el de prior más
180 // bajo (1 + 2·√2 ≈ 3,83) todavía NO le pasan al de prior más alto con
181 // uno (2 + 2 = 4) — hacen falta cuatro (1 + 4 = 5). Está bien que sea
182 // así: un asiento que se da vuelta al segundo click es un asiento que
183 // se mueve solo debajo de la mano.
184 assert!(u.score(T::Uno) < u.score(T::Dos), "el prior dejó de pesar demasiado pronto");
185 let mut u = u;
186 u.bump(T::Uno);
187 u.bump(T::Uno);
188 let u = Uso::abrir(Some(p.clone()));
189 assert_eq!(u.usos(T::Uno), 4);
190 assert!(u.score(T::Uno) > u.score(T::Dos), "cuatro clicks no alcanzaron a correr el asiento");
191 let _ = std::fs::remove_dir_all(p.parent().unwrap());
192 }
193
194 /// La clave es `&'static str` **a propósito**: el registro tiene que
195 /// sobrevivir a que alguien reordene las variantes del enum o retire una.
196 /// Un archivo con una clave huérfana se ignora sola, sin romper nada.
197 #[test]
198 fn manda_la_clave_y_una_huerfana_no_molesta() {
199 let p = ruta("clave");
200 let _ = std::fs::remove_dir_all(p.parent().unwrap());
201 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
202 std::fs::write(&p, br#"{"dos": 5, "retirado": 99}"#).unwrap();
203 let u = Uso::abrir(Some(p.clone()));
204 assert_eq!(u.usos(T::Dos), 5, "no leyó por clave");
205 assert_eq!(u.usos(T::Uno), 0);
206 let _ = std::fs::remove_dir_all(p.parent().unwrap());
207 }
208
209 /// Un contador de comodidad **no puede ser motivo de que la app no
210 /// levante**. Con el archivo roto arranca de cero (manda el prior) y el
211 /// primer click lo deja sano otra vez.
212 #[test]
213 fn un_archivo_roto_no_impide_levantar_ni_queda_roto() {
214 let p = ruta("roto");
215 let _ = std::fs::remove_dir_all(p.parent().unwrap());
216 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
217 std::fs::write(&p, b"no soy json").unwrap();
218 let mut u = Uso::abrir(Some(p.clone()));
219 assert_eq!(u.usos(T::Uno), 0);
220 u.bump(T::Uno);
221 assert_eq!(Uso::abrir(Some(p.clone())).usos(T::Uno), 1, "no se recuperó al primer click");
222 let _ = std::fs::remove_dir_all(p.parent().unwrap());
223 }
224
225 /// La escritura es `.tmp` + `rename` para que un corte a mitad deje el
226 /// archivo viejo entero. La contracara: el temporal **no se queda tirado**
227 /// al lado del bueno — si queda, el rename no ocurrió y lo que se lee la
228 /// próxima vez es el archivo de antes.
229 #[test]
230 fn escribir_no_deja_el_temporal_tirado() {
231 let p = ruta("tmp");
232 let _ = std::fs::remove_dir_all(p.parent().unwrap());
233 let mut u = Uso::abrir(Some(p.clone()));
234 u.bump(T::Dos);
235 assert!(p.exists(), "no escribió el registro");
236 assert!(!p.with_extension("json.tmp").exists(), "quedó el temporal al lado");
237 let _ = std::fs::remove_dir_all(p.parent().unwrap());
238 }
239
240 /// Sin ruta el uso vive en memoria y **no escribe nada**: es el modo que
241 /// usa el kernel de wawa, donde no hay a dónde escribir.
242 #[test]
243 fn sin_ruta_el_uso_vive_en_memoria() {
244 let mut u = Uso::abrir(None);
245 u.bump(T::Uno);
246 assert_eq!(u.usos(T::Uno), 1);
247 }
248}