tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 229TOML 101Markdown 82Shell 24052 Definiciones

⎇ main widgets/fugaces/src/uso.rs Rust · 248 líneas · 10.0 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos b3f12e136 · Sergio ·

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}