El estado vive fuera del modelo: cómo una mascota de escritorio sabe que llevas tres días sin hablarle
Un LLM con memoria da un chatbot amable. Una máquina de estados con reloj inyectado da una criatura. La frase «I know» solo tiene gracia si es verdad.
NOVA es una criatura de escritorio con ojos, memoria y carácter. Todo el proyecto cuelga de un guion de cuatro líneas:
Tú: «I built a ChatGPT pet.» La criatura: «You haven’t talked to me in 3 days.» Tú: «What?» La criatura: «I know.»
Lo obvio sería enchufar un modelo de lenguaje con memoria y dejar que improvise. El resultado de eso es un chatbot amable, no una criatura. Y la línea de los tres días, si la genera el modelo, es una frase plausible. Si la genera un temporizador, es verdad. La diferencia se nota en el único momento que importa.
La decisión
El estado vive fuera del modelo. Son cuatro valores: afecto, energía, humor y última interacción. Decaen con el tiempo según reglas propias, con sus tests. Las entradas (hoy tres botones, mañana una clasificación de sentimiento sobre lo que dices) inyectan valores. Y hay disparadores proactivos: el dispositivo habla sin que le hables porque un temporizador se cumplió.
El modelo recibe ese estado en el prompt y escribe la línea. A veces ni eso: hay frases escritas a mano. «Un LLM con memoria» frente a «un Tamagotchi que sabe hablar». Lo segundo es lo que quiero construir.
El dominio, sin framework
El módulo de personalidad es Java puro: cero dependencias, ni una anotación
de Spring. Spring lo envuelve desde fuera y lo expone por WebSocket; la
lógica no sabe que existe un framework, y los tests corren en milisegundos.
Cuatro clases: Creature (la máquina de estados), Tuning (todas las
constantes de personalidad en un solo sitio), Snapshot (lo que se entrega
al dispositivo) y los enumerados.
Cinco decisiones que aguantan:
- Decaimiento exponencial continuo, no por pasos. Tres días en un salto dan exactamente lo mismo que 72 saltos de una hora, y hay un test que lo comprueba. Sin esa propiedad, un dispositivo que se desconecta una tarde vuelve con el estado mal, y ese bug es infernal de encontrar en una placa.
- El humor revierte hacia una línea base marcada por el afecto. El vínculo largo fija el estado de reposo; los eventos lo perturban. Es una línea de código y es lo que hace que la criatura sea coherente consigo misma.
- Rendimientos decrecientes en los botones. Una variable de saturación que decae. Sin esto, machacar el corazón sube el afecto al máximo y la mecánica se nota falsa en diez segundos.
- La energía no se persiste: se deriva de la hora local. Función pura del reloj, mínimo a las 4:00, máximo a las 16:00. No hay nada que guardar.
- El
Snapshotno devuelve geometría. Ni un ángulo de párpado ni una curva: devuelve qué siente y a qué ritmo. La forma vive en el firmware, y eso permite rediseñar la cara entera sin tocar la personalidad.
El tiempo se inyecta
Toda la lógica depende del paso del tiempo, y depender del reloj de pared
haría imposible probarla. Creature recibe un java.time.Clock en el
constructor. En producción es el reloj del sistema; en los tests lo controlo
yo, y simulo tres días, una semana o seis meses de silencio en milisegundos,
sin la placa encima de la mesa.
Esta es, de paso, la razón de elegir Java sobre Python o Node para el
backend: Clock es literalmente la fuente de tiempo inyectable que pide el
diseño. La clase existe para esto. En otros lenguajes habría acabado con
monkeypatching o una abstracción a mano.
Al añadir el histórico de eventos apareció un fallo que merece la pena contar. El reloj de repetición nacía por defecto en 1970 hasta que alguien lo colocaba en una fecha. Lo arreglé quitándole al constructor la posibilidad de nacer sin saber cuándo: el instante inicial pasa a ser obligatorio. Los objetos que pueden nacer en un estado inválido acaban naciendo en un estado inválido.

La ética, como constantes con test
La mecánica de «se pone triste si la ignoras» es el gancho del producto y a
la vez el riesgo: encantador o coercitivo. Decidí que eso no fuera una
intención sino tres números en Tuning, cada uno con su test:
| Constante | Valor | Qué garantiza |
|---|---|---|
affectionRecoveryFactor | 2,5 | El afecto sube 2,5 veces más rápido de lo que baja. Volver siempre compensa más que irse. |
affectionFloor | 15 | Nunca llega a cero. No hay criatura muerta que dé culpa mirar. |
neglectCooldown | 24 h, sin escalado | Lo menciona como mucho una vez al día y nunca sube el tono. |
No es «lo dice una vez y nunca más», que sería peor: si te vas dos meses, lo mencionaría el día tres y callaría para siempre. Es como mucho una vez al día, sin escalar. Si algún día alguien afloja esos tres números, que sea sabiendo lo que afloja.
Una línea temporal
arranque afecto= 55.0 humor= +0.0 NEUTRAL CALM
❤️ afecto= 75.0 humor= +30.0 HAPPY CALM
+2 h afecto= 74.5 humor= +19.9 NEUTRAL CALM
😡 afecto= 72.5 humor= -21.3 HURT CALM
+8 h afecto= 70.7 humor= +6.4 NEUTRAL TIRED <- el enfado se pasó solo
+3 días afecto= 56.4 humor= +2.5 NEUTRAL TIRED -> dispara NEGLECTED
+2 semanas afecto= 28.9 humor= -8.4 BORED TIRED
❤️ vuelve afecto= 48.9 humor= +21.6 HAPPY TIRED <- recuperación asimétricaQueda por ajustar conviviendo con ella, no teorizando: ¿dos semanas de silencio deberían dejarla en 28? ¿Un solo corazón debería subirla de 28 a 48? Pero la pregunta ya es sobre dos números concretos, no sobre una sensación, y esa es la ventaja de haber sacado el estado del modelo.
Lo que me llevo
Cuando algo tiene que ser verdad, no se lo pidas a un generador de texto. El modelo es bueno verbalizando; es malo sabiendo. El estado, el tiempo y las reglas viven en código con tests, y el modelo recibe el resultado y lo pone en palabras. Es una arquitectura menos vistosa que «una IA con memoria», y es la única en la que «I know» significa algo.