La ingeniería que hay detrás
Vrain decide sobre datos de salud y prescribe esfuerzo físico. Eso obliga a que la decisión sea reproducible, auditable y explicable sin fe: mismos datos y misma fecha, misma sesión, con el porqué generado de la propia decisión. Esta página cuenta cómo se consigue, con los números y los nombres del código.
- 18
- Tablas, con el esquema agnóstico del proveedor
- 362
- Tests de dominio, 281 de ellos sobre el motor puro
- 0
- Dependencias del paquete de dominio
- 0
- Cifras que escribe un modelo de lenguaje
La arquitectura
Un dominio puro en el centro y adaptadores en los bordes
Todo lo que decide vive en un paquete sin red, sin base de datos y sin framework. Lo que habla con el mundo son adaptadores, y se pueden cambiar de uno en uno sin tocar una sola regla.
El esquema no sabe de Strava
activities y recovery_daily llevan columna source y ningún campo propio de un proveedor. Esa es la regla que hace barato cambiar de origen: en cuanto un campo específico se cuela en el modelo, deja de serlo.
El núcleo no importa nada
packages/core no conoce Next ni la base. Se puede ejecutar en un test, en un script o en otro producto, y es lo que permite tener cobertura de verdad sobre las reglas en vez de sobre la fontanería.
Ninguna fuente es única
Si un conector cae o cierra su API, los datos siguen pudiendo entrar por fichero. El respaldo .FIT no es una función de más: es lo que impide que un tercero sea un punto único de fallo.
El recorrido
De un fichero en el servidor de Strava al intervalo en el Edge
Cinco pasos, cada uno con una regla que se ganó mirando qué salía mal. La más cara de aprender fue que el orden entre ellos importa.
- 01
Entra
Strava empuja por webhook y una pasada incremental recoge lo que el webhook perdiera. Google Health se pide en ventana móvil de siete días, porque una noche se afina durante horas y un cursor congelaría la primera versión, que es la peor.
activitiesactivity_streamsrecovery_daily - 02
Se valora
Cada actividad se puntúa con el FTP vigente en su fecha y conserva cuál fue. Mejorar en primavera no puede reescribir tu forma de octubre, y por eso el histórico de FTP es una tabla y no un campo.
athlete_ftp_historyactivities.ftp_at_time_watts - 03
Se estima lo que no se midió
Donde hubo potenciómetro, la carga es medida. Donde no, se estima desde el pulso con un modelo calibrado con tu propio histórico, y se guarda en otra columna. Las dos entran en la curva y en todo momento se sabe cuál es cuál.
activities.estimated_tssfitness_daily.estimated_share - 04
Se decide y se reparte
El motor juzga la sesión del día contra tus señales; el planificador reparte los siete siguientes desde tu ritmo observado y la fase de temporada. Los dos son deterministas y los dos guardan su versión.
plan_dayscoach_decisions - 05
Sale y se juzga
La semana se escribe en el calendario de intervals.icu y baja sola al dispositivo, y sale también como calendario .ics al que se suscribe tu agenda. Cuando la actividad vuelve, se compara con lo que se prescribió en tres dimensiones separadas —volumen, carga y series—, nunca en una nota media.
plan_days.complianceintervals.icu.ics
El orden no es una manía
Primero se valora lo medido, después se estima lo que no se midió, y solo entonces se juzga el cumplimiento. Estimar antes usaría una recta ajustada con cargas viejas; juzgar antes de estimar dejaría el día valorado contra un hueco. Es una línea de código en una ruta y tres semanas de curva torcida si se pone al revés.
Lo medido y lo estimado
Un bloque entero de gimnasio no puede ser un mes sin entrenar
Solo hay carga medida donde hubo potenciómetro. Sin más, la curva de forma decaía en los meses en que sí se entrenaba, y para el motor aquel bloque sencillamente no había ocurrido.
Una recta con dos anclas —el pulso donde el IF vale 0 y el pulso donde vale 1—. La linealidad no es la última palabra en fisiología, pero sobre la media de una sesión entera se porta bien y cualquier cosa más fina pediría parámetros que aquí no se pueden medir. El ejemplo dibuja las anclas de manual sobre una FC máxima de 185 lpm.
Las anclas son tuyas
Con 12 salidas que lleven potencia y pulso a la vez, y al menos 20 lpm de recorrido entre ellas, la recta se ajusta con tu propio histórico: ahí se conocen las dos caras. No hace falta ni umbral de pulso ni FC en reposo, y ese era el problema, porque no los guarda nadie.
La pendiente sale por Theil–Sen —la mediana de las pendientes de todos los pares—, que aguanta hasta un 29% de datos corruptos. Y los extremos aquí son la norma: el pulsómetro suelto marcando 40, el día de calor, la carrera.
El gimnasio va por duración
Levantar pesado deja el pulso bajo entre series mientras la sesión es cara de verdad: estimarla con el modelo aeróbico la contaría como un paseo, que es justo el error que se venía a corregir. Se usa una tarifa declarada de 35 puntos por hora, y se dice que es declarada y no medida.
Techo de IF en 1,15
El pulso no sabe de esfuerzos cortos y sí sabe de la fiebre y del calor. Sin tope, un dato raro produce una sesión de 400 puntos que arrastra la forma seis semanas.
Sin estimación posible, hueco
Nunca un cero. Un cero en la curva significa «descansó», que es una afirmación distinta de «no sabemos medirlo», y las dos se propagan.
La potencia siempre manda
En cuanto una actividad tiene medida, su estimación deja de escribirse. Lo estimado se marca en la interfaz con una tilde y en tono apagado: entra en la curva, pero no se disfraza de medida.
El FTP
No es un resultado: es la entrada de la que cuelga todo lo demás
Con el FTP bajo, el TSS sale inflado, la forma sube sola y el plan presupuesta de más; con el FTP alto, pasa lo contrario y se entrena por debajo. Un valor viejo no da un error: da un sistema entero corrido.
Calculado al renderizar esta página con la misma función que usa la app, sobre una curva de muestra. Un FTP suelto no permite saber si las demás rutas decían lo mismo, y esa concordancia es la mitad de lo que hay que juzgar.
Se toma el más alto, y no es optimismo
Aquí no se administra un test: se rebusca en lo que ya se hizo. Cada factor está calibrado para un esfuerzo máximo, así que sobre esfuerzos encontrados cada protocolo responde «tu FTP es al menos esto». Con tres cotas inferiores de la misma magnitud, quedarse con la más baja es descartar una medida por otra que ya se sabe peor.
Lo que eso no arregla es el sesgo de fondo: si no hubo máximo en ninguna de las tres duraciones, la más alta también se queda corta. Por eso el número es un suelo y no una medida, y la interfaz no empuja a bajar el FTP guardado por estar por encima de él.
La CP entra descontada
La potencia crítica es la asíntota de un modelo y el FTP una convención: en ciclistas entrenados la primera queda por encima. Hay dos medidas publicadas de esa diferencia y no coinciden — 7 W en Karsten (2021), 16 W en McGrath (2021)—, así que se guardan las dos y se descuenta la media. Elegir una fingiría una precisión que la literatura no tiene.
El aviso está en el 5%
Y es una convención de presentación declarada, no fisiología: dice cuándo avisar de que las rutas no concuerdan, no cuándo el FTP está mal. La dispersión se mide solo entre protocolos, porque son la misma clase de dato con distinto descuento.
La ventana es de 90 días
Suficiente para que haya caído algún esfuerzo largo de verdad, corta para que describa la forma de ahora. Con un año, el FTP estimado de alguien en pretemporada seguiría siendo el de julio.
No lo escribe, y es deliberado
Strava es la fuente de verdad; tener el dato en dos sitios garantiza que uno se quede viejo. La app estima, enseña la evidencia —qué esfuerzo, de qué día— y decide el atleta.
El motor
Ninguna señal aislada manda parar
La variabilidad día a día de la HRV y del pulso en reposo es alta, y un mal dato tras una sesión dura es lo normal, no una alarma. Lo que manda descansar es la convergencia, y está escrito en números que se pueden discutir.
Los umbrales
leídos de THRESHOLDS, el mismo objeto que usa el motor
| Señal | Atención | Aviso | Crítico |
|---|---|---|---|
| HRV bajo su base de 30 días | -8% | -15% | — |
| FC en reposo sobre su base | 3lpm | 6lpm | — |
| Déficit de sueño de anoche | 60min | 120min | — |
| Déficit de sueño acumulado a 3 días | — | 240min | — |
| TSB · frescura | -20 | -30 | -40 |
| Puntos de forma ganados en 7 días | 7CTL | 10CTL | — |
| Días duros seguidos | 3días | 4días | — |
| Monotonía de Foster sobre 7 días | 1,5 | 2 | — |
| Preparación del dispositivo (0-100) | 65 | 50 | 35 |
Ni la HRV ni el pulso en reposo llegan a «crítico» por sí solos, a propósito. Un día duro sube uno de los dos casi siempre; los dos a la vez, con el sueño corto encima, ya no.
Lo que sí manda descansar: la convergencia
HRV suprimida, pulso en reposo elevado y temperatura de piel por encima de su base, las tres a la vez. Es el patrón clásico de una infección incubándose, y ninguna de las tres por separado dice nada.
La base de cada señal es su media móvil de 30 días, la del propio atleta. No hay tablas de población: lo que cuenta es cuánto te separas de ti.
De las banderas a la decisión
El mismo cuadro de señales se trata distinto según lo que tocaba hacer
| Señales | Sesión exigente | Sesión suave |
|---|---|---|
| Una crítica | Descanso | Descanso |
| Dos avisos o más | Recuperación | Recuperación |
| Un aviso | Recuperación | Recortar |
| Dos atenciones | Recortar | Adelante |
| Tres atenciones o más | Recortar | Recortar |
| Nada | Adelante | Adelante |
La serie diaria es densa
Los días sin entrenar son ceros y no huecos. Sin el cero, la cuenta de días duros seguidos no para en el descanso, y la monotonía de una semana corriente pasa de 1,39 a 1,82 y cruza el umbral de aviso ella sola.
Un día es duro con IF 0,85 o 90 puntos
Es el único umbral que comparten el motor y el planificador, y a propósito: con dos definiciones, el motor podría avisar de días duros seguidos justo los que el generador acababa de encadenar.
La serie acaba ayer
El motor prescribe la sesión de hoy y hoy todavía no ha terminado. Cerrar la serie en hoy metería un cero al final y la racha de días duros saldría siempre a cero.
El modelo de lenguaje
Traduce a la entrada; no decide, no redacta cifras
Hay un modelo en el circuito y entra por donde no rompe nada. Lee la nota que escribe el atleta —«las series me supieron a poco pero llego con las piernas cargadas, y el martes estoy de viaje»— y la convierte en un puñado de ajustes de vocabulario cerrado. Quien reparte la semana sigue siendo el generador determinista.
El vocabulario entero, y no tiene sitio para una cifra
Cuánto vale cada escalón es código con tests. Si el modelo alucina, alucina un entero entre −2 y +2 que la aduana recorta igualmente
| Campo | Valores | Qué mueve |
|---|---|---|
| intensityBias | −2 … +2 | Cada escalón mueve un 15% el presupuesto de la sesión de series. No mueve el de la semana: por este eje ninguna frase puede producir una semana insegura. |
| loadBias | −2 … +2 | Cada escalón vale un 7,5% del presupuesto semanal, y con el tope son un 15%: exactamente lo que el planificador ya se permite moverse entre semanas por su cuenta. |
| strength | skip · light · heavy | Quitar el gimnasio siempre se puede; ponerlo, no. Que alguien diga «esta semana le pego al gimnasio» no convierte un afinado en una semana de sentadillas. |
| unavailableDates | fechas de la ventana | Los días que se cierran. Se guardan donde ya vivían los que se marcan a mano, así que sobreviven a regenerar y la casilla del selector se marca sola. |
Los sesgos van por dentro
La nota multiplica el objetivo antes de que lo recorten los tres frenos del presupuesto —la progresión de forma, el salto máximo entre semanas y las horas que caben—. Aplicarla después sería saltárselos: una frase mueve la intención de la semana, nunca la fisiología.
Lo que no se entiende no se inventa
Directivas vacías producen exactamente el plan de siempre, y esa propiedad tiene su test. Es lo que hace que la traducción pueda fallar entera —sin clave, sin red, con una respuesta ilegible— sin llevarse nada por delante. Sin modelo configurado, el cuadro no aparece y la semana se genera igual.
El eco es de plantilla, no del modelo
Antes de regenerar se enseña la lectura estructurada —«Martes 8 · no disponible · Intensidad +1 · Volumen −7,5%»— y entonces decide el atleta. Ese texto sale de las directivas, no de una redacción: un modelo escribiendo el eco podría decir «bajo el volumen» sobre unas directivas que lo suben, y entonces el eco dejaría de servir para lo único que sirve.
Auditabilidad
Cuatro versiones, porque son cuatro artefactos distintos
El motor juzga si hoy se entrena; el planificador propone qué; el cumplimiento mira hacia atrás; las directivas dicen qué se entendió. Cambiar uno no invalida lo que decidió otro, así que cada uno lleva la suya y se persiste con lo que produjo.
Con qué reglas se decidió si hoy se entrena
Con qué reglas se repartió la semana
Con qué umbrales se juzgó lo que se hizo
Qué se entendió de la nota del atleta
Se guarda la entrada entera
Cada decisión persiste el contexto con el que se tomó, no un resumen. Con eso y la versión se puede reejecutar el motor meses después y obtener exactamente lo mismo, aunque las reglas ya hayan cambiado.
En pantalla se lee de la fila
Nunca de la constante. La constante dice con qué generaría el código de ahora; la fila, con qué se generó lo que se está mirando. Son el mismo número casi siempre, y dejan de serlo justo el día en que la etiqueta empieza a importar.
La variedad no es aleatoria
Que dos semanas no traigan las mismas series sale de rotar según la fecha, no de tirar un dado. El mismo lunes da la misma sesión: si no, «mismos datos, mismo plan» sería una frase bonita y nada más.
La operación
Dos anfitriones y los mismos tres trabajos
Lo que se ejecuta es lo mismo corra donde corra; lo que cambia es quién lo llama y cada cuánto. Cuando el bucle de atletas vivía dentro del worker, desplegar obligaba a copiarlo, y dos copias de un bucle son dos sitios donde arreglar el mismo fallo.
Desplegado
Las actividades entran empujadas por webhook. Un cron diario sincroniza y después recalcula —en ese orden, o la curva se queda un día por detrás de lo que acaba de entrar—, y ahí la sincronización no es la vía principal sino la red: un webhook se pierde si la app estaba caída o si la suscripción caducó.
- 03:10 UTCSincronizar y recalcular la forma
- 09:00 UTCRecuperación, en su propia ruta y a otra hora
La recuperación va aparte por el reloj, no por manía de orden: a las 03:10 UTC son las cinco de la madrugada en España y la noche todavía no ha terminado. Metida en la diaria, la pantalla habría enseñado siempre el sueño de anteayer.
Autoalojado
Detrás de un router doméstico el webhook no puede ser —abrir puertos, o un túnel cuya URL cambia en cada arranque y rompe la suscripción—, así que manda el sondeo. Las colas van en el propio Postgres: un contenedor menos que operar.
- cada 30 minSondeo de actividades
- cada 3 hSueño y señales de la noche
- 03:10 diarioExtender la curva de forma hasta hoy
El tercero es el menos evidente y hace falta: la forma y la fatiga bajan cuando no se entrena, pero solo se recalculan al importar. Sin él, una semana sin salir dejaría la curva congelada enseñando una forma que ya no es real.
Cinco detalles que solo aparecen en producción
| Decisión | Por qué |
|---|---|
| Una sola conexión por instancia | En un servidor hay un proceso y un pool, y diez conexiones son diez. En serverless hay una instancia por petición concurrente, cada una con su pool: diez pasan a ser diez por lambda viva, y un Postgres gestionado se queda sin cupo con muy poco tráfico. |
| El trabajo del webhook se espera de verdad | La instancia se congela al devolver la respuesta: una promesa que nadie espera se queda a medias y la actividad no entra. Hay que mantener viva la invocación hasta que termine. |
| Las migraciones usan el driver de la app | La herramienta de migración trae el suyo, y contra un Postgres gestionado que exige TLS con CA propia se cuelga y termina sin imprimir nada. Con el mismo driver, si la app conecta, esto migra. |
| Las rutas programadas llevan llave | No hay sesión detrás de un cron, así que son públicas por definición. Sin la variable del secreto responden 503: una puerta que se abre al faltar su llave no es una puerta. |
| Nada lee el entorno al importar el módulo | El framework importa todas las rutas al compilar para recoger su configuración, así que una conexión creada al cargar el módulo convierte la URL de la base en un requisito del build. Y compilar no consulta ninguna base. |
Los límites
Lo que no hace, dicho aquí y no en la letra pequeña
Un hueco que se enseña se puede juzgar; uno que se disimula se descubre con el sistema ya en marcha. Estos son los seis que hoy tiene el producto, con la razón por la que siguen ahí.
Sin potenciómetro no hay carga medida
La potencia que deduce Strava de la pendiente y el peso daría cifras inventadas que contaminarían la forma y las decisiones. Se estima desde el pulso, se guarda en otra columna y se enseña marcada.
La preparación del dispositivo se queda vacía
La puntuación de preparación no tiene todavía tipo de dato publicado en la API de salud, y darle al motor un número que nadie midió es exactamente lo que este proyecto no hace. La regla existe y espera al dato.
Solo hay veredicto donde hubo plan
Una actividad anterior a que existiera un plan se queda sin etiqueta de cumplimiento, y así debe ser: juzgarla contra una plantilla que nunca se le propuso no mediría el cumplimiento de nada.
La proyección de temporada es una simulación
Supone que el plan se cumple entero y que no hay gripes, viajes ni lluvia. Su valor no está en el número exacto de dentro de cuatro meses, sino en responder si el pico cae donde está la carrera y con qué frescura se llega.
El catálogo de sesiones es de ciclismo
Una prueba a pie se guarda con su disciplina, pero la especificidad se calcula sobre la bici. Periodizar una carrera a pie de verdad pide un catálogo de sesiones de carrera estructurada, y ese todavía no existe.
La nota del atleta no tiene memoria
Se aplica a la generación que la acompaña y no se arrastra, que es lo correcto para «el martes estoy de viaje». Pero deja fuera lo que sí es tendencia: quien lleva tres semanas diciendo que las series le saben a poco está diciendo que su FTP está viejo.
En toda la aplicación, un hueco significa «no se sabe» y nunca «no lo hubo». Es la misma distinción que separa un cero de un vacío en la curva de forma, y la única que permite que un número se pueda discutir.
Un plan que no se puede auditar no es un plan: es una opinión con gráficos.
Tu histórico ya sabe cómo entrenas —qué días sales, cuál es tu día largo, cuánta carga aguantas— y de las últimas 12 semanas sale la semana que viene. La forma y la fatiga se promedian a 42 y 7 días, los umbrales están escritos arriba y cada decisión guarda con qué se tomó.