//! El **registro de uso** de los fantasmas: cuántas veces se clickeó cada uno. //! //! Es la mitad del asiento aprendido que toca el disco, y por eso vive tras la //! feature `persistencia`: sin ella el crate es `no_std` puro y el uso queda en //! memoria (manda el prior), que es lo que necesita el kernel de wawa. //! //! **La ruta la elige el consumidor.** Este crate no sabe cómo se llama la app //! ni dónde guarda sus cosas; recibe un `PathBuf` y se ocupa de que escribir no //! pueda dejar el archivo a medias. extern crate std; use alloc::string::{String, ToString}; use std::collections::HashMap; use std::path::PathBuf; use crate::Fantasma; /// Cuántos clicks lleva cada fantasma, por [`Fantasma::clave`]. /// /// Nunca falla: sin archivo, sin permiso o con JSON roto arranca de cero y el /// prior manda. Un contador de comodidad no puede ser motivo de que la app no /// levante. #[derive(Default, Debug)] pub struct Uso { usos: HashMap, path: Option, } impl Uso { /// Abre (o crea vacío) el registro en `path`. `None` = el uso vive en /// memoria y se pierde al cerrar. pub fn abrir(path: Option) -> Self { let usos = path .as_ref() .and_then(|p| std::fs::read(p).ok()) .and_then(|b| serde_json::from_slice(&b).ok()) .unwrap_or_default(); Self { usos, path } } /// Cuántos clicks lleva `f`. pub fn usos(&self, f: F) -> u32 { self.usos_de(f.clave()) } /// Registra un uso (click) de `f` y persiste. pub fn bump(&mut self, f: F) { self.bump_de(f.clave()); } /// Puntaje de asiento de `f` — [`crate::score`] con el uso de este registro. pub fn score(&self, f: F) -> f32 { crate::score(f, self.usos(f)) } // ─── La misma cuenta, con la clave CRUDA ──────────────────────────────── // // Un [`Fantasma`] tiene clave `&'static str` porque su población es un enum // que se escribe a mano. Hay poblaciones que no: las **categorías de // aplicaciones** de pata salen del catálogo de `.desktop` de la máquina, así // que su clave es un `String` que nadie puede declarar de antemano. // // Es la misma pregunta («¿cuánto usás esto?»), el mismo archivo y la misma // curva, así que se generaliza el registro en vez de fabricarle un gemelo al // lado (Regla 10 del repo, y lo que pH7.b dejó anotado: *«lo que falta no es // el mecanismo: es generalizarlo para que la clave no sea sólo un `Fugaz`»*). /// Cuántos usos lleva la clave `k`. pub fn usos_de(&self, k: &str) -> u32 { self.usos.get(k).copied().unwrap_or(0) } /// Registra un uso de la clave `k` y persiste. pub fn bump_de(&mut self, k: &str) { *self.usos.entry(k.to_string()).or_insert(0) += 1; self.persist(); } /// Puntaje de la clave `k` con el `prior` que le dé el consumidor: la misma /// curva que [`crate::score`] (`prior + 2·√usos`, que satura), para que dos /// poblaciones distintas no aprendan a ritmos distintos. pub fn score_de(&self, k: &str, prior: f32) -> f32 { prior + 2.0 * (self.usos_de(k) as f32).sqrt() } /// Suma de todos los usos registrados. Es el reloj con que un consumidor /// decide cuándo [`Uso::envejecer`]: no hay fechas en el archivo, y meterlas /// haría que dos máquinas con relojes distintos aprendieran distinto. pub fn total(&self) -> u32 { self.usos.values().sum() } /// **Envejecer**: divide a la mitad todos los conteos y suelta los que /// llegan a cero. /// /// Es la diferencia entre «lo que más usaste **alguna vez**» y «lo que más /// usás **últimamente**». Un contador que sólo sube nunca olvida: algo que /// usaste veinte veces el mes pasado le gana para siempre a lo que usás hoy, /// y el asiento deja de moverse. Halvar es el olvido exponencial de siempre /// (el de los cachés LFU): barato, sin fechas, y conserva el ORDEN entre los /// que sobreviven — lo que cambia es cuánto cuesta pasar al de adelante. /// /// No lo llama el registro solo: **el consumidor decide cada cuánto**, con /// [`Uso::total`] como reloj. Los fantasmas no lo usan (su población es fija /// y su prior manda); las categorías de aplicaciones sí. pub fn envejecer(&mut self) { self.usos.values_mut().for_each(|v| *v /= 2); self.usos.retain(|_, v| *v > 0); self.persist(); } /// Escritura atómica: se escribe a `.tmp` y se renombra. Un corte de luz a /// mitad deja el archivo viejo entero, no uno truncado que después no /// parsea (y que sería indistinguible de «nunca usaste nada»). fn persist(&self) { let Some(path) = &self.path else { return }; let Ok(bytes) = serde_json::to_vec_pretty(&self.usos) else { return }; if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); } let tmp = path.with_extension("json.tmp"); if std::fs::write(&tmp, &bytes).is_ok() { let _ = std::fs::rename(&tmp, path); } } } #[cfg(test)] mod tests { use super::*; use alloc::format; #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum T { Uno, Dos, } impl Fantasma for T { fn clave(self) -> &'static str { match self { T::Uno => "uno", T::Dos => "dos", } } fn prior(self) -> f32 { match self { T::Uno => 1.0, T::Dos => 2.0, } } } /// Un archivo propio por test: corren en paralelo dentro del mismo proceso /// y compartir la ruta haría que se pisen los conteos entre sí. fn ruta(nombre: &str) -> PathBuf { std::env::temp_dir() .join(format!("fugaces-uso-{}-{nombre}", std::process::id())) .join("uso.json") } /// **La mitad que toca el disco.** Que el asiento se aprenda no sirve de /// nada si se olvida al cerrar la app — y esa mitad no la cubre ningún test /// de la máquina, porque la máquina no sabe que el disco existe. #[test] fn el_asiento_aprendido_sobrevive_a_cerrar_la_app() { let p = ruta("sobrevive"); let _ = std::fs::remove_dir_all(p.parent().unwrap()); let mut u = Uso::abrir(Some(p.clone())); u.bump(T::Uno); u.bump(T::Uno); u.bump(T::Dos); drop(u); // cerrar la app let u = Uso::abrir(Some(p.clone())); assert_eq!(u.usos(T::Uno), 2, "el conteo no volvió del disco"); assert_eq!(u.usos(T::Dos), 1); // Y el uso mueve el asiento, pero **despacio**: el puntaje es // `prior + 2·√usos`, o sea que satura. Dos clicks sobre el de prior más // bajo (1 + 2·√2 ≈ 3,83) todavía NO le pasan al de prior más alto con // uno (2 + 2 = 4) — hacen falta cuatro (1 + 4 = 5). Está bien que sea // así: un asiento que se da vuelta al segundo click es un asiento que // se mueve solo debajo de la mano. assert!(u.score(T::Uno) < u.score(T::Dos), "el prior dejó de pesar demasiado pronto"); let mut u = u; u.bump(T::Uno); u.bump(T::Uno); let u = Uso::abrir(Some(p.clone())); assert_eq!(u.usos(T::Uno), 4); assert!(u.score(T::Uno) > u.score(T::Dos), "cuatro clicks no alcanzaron a correr el asiento"); let _ = std::fs::remove_dir_all(p.parent().unwrap()); } /// La clave es `&'static str` **a propósito**: el registro tiene que /// sobrevivir a que alguien reordene las variantes del enum o retire una. /// Un archivo con una clave huérfana se ignora sola, sin romper nada. #[test] fn manda_la_clave_y_una_huerfana_no_molesta() { let p = ruta("clave"); let _ = std::fs::remove_dir_all(p.parent().unwrap()); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::write(&p, br#"{"dos": 5, "retirado": 99}"#).unwrap(); let u = Uso::abrir(Some(p.clone())); assert_eq!(u.usos(T::Dos), 5, "no leyó por clave"); assert_eq!(u.usos(T::Uno), 0); let _ = std::fs::remove_dir_all(p.parent().unwrap()); } /// Un contador de comodidad **no puede ser motivo de que la app no /// levante**. Con el archivo roto arranca de cero (manda el prior) y el /// primer click lo deja sano otra vez. #[test] fn un_archivo_roto_no_impide_levantar_ni_queda_roto() { let p = ruta("roto"); let _ = std::fs::remove_dir_all(p.parent().unwrap()); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::write(&p, b"no soy json").unwrap(); let mut u = Uso::abrir(Some(p.clone())); assert_eq!(u.usos(T::Uno), 0); u.bump(T::Uno); assert_eq!(Uso::abrir(Some(p.clone())).usos(T::Uno), 1, "no se recuperó al primer click"); let _ = std::fs::remove_dir_all(p.parent().unwrap()); } /// La escritura es `.tmp` + `rename` para que un corte a mitad deje el /// archivo viejo entero. La contracara: el temporal **no se queda tirado** /// al lado del bueno — si queda, el rename no ocurrió y lo que se lee la /// próxima vez es el archivo de antes. #[test] fn escribir_no_deja_el_temporal_tirado() { let p = ruta("tmp"); let _ = std::fs::remove_dir_all(p.parent().unwrap()); let mut u = Uso::abrir(Some(p.clone())); u.bump(T::Dos); assert!(p.exists(), "no escribió el registro"); assert!(!p.with_extension("json.tmp").exists(), "quedó el temporal al lado"); let _ = std::fs::remove_dir_all(p.parent().unwrap()); } /// Sin ruta el uso vive en memoria y **no escribe nada**: es el modo que /// usa el kernel de wawa, donde no hay a dónde escribir. #[test] fn sin_ruta_el_uso_vive_en_memoria() { let mut u = Uso::abrir(None); u.bump(T::Uno); assert_eq!(u.usos(T::Uno), 1); } }