En desarrollo · 2026
OpoStudy — Oposiciones con repetición espaciada
Preparación de oposiciones para Mossos d’Esquadra. Van cinco fases de diez: el diseño, el backend, el catálogo, el registro de respuestas y el algoritmo de repetición espaciada. Todavía no hay interfaz de estudio ni despliegue.
- Rol
- Diseño y desarrollo
- Fases completadas
- 5 de 10
- Registros de decisión
- 7
- Tests
- 123
Problema
Preparar una oposición es retener mucha información durante meses. Un test en PDF no sabe qué has fallado: repites lo que ya dominas y no vuelves a ver lo que erraste hace tres semanas, que es justo lo que se te ha olvidado.
Y las aplicaciones de test genéricas presentan igual una pregunta sacada del BOE y una inventada. Estudiar sobre contenido incorrecto es peor que no estudiar: consolida el error y no se nota hasta el examen.
Decisión
Escribir el diseño antes que el código. Siete registros de decisión y cinco documentos de arquitectura —modelo de datos, API, algoritmo, despliegue— están fechados antes de la primera migración: unas 3.600 líneas decidiendo qué entidades existen y por qué las relaciones son las que son.
De ahí salieron tres decisiones de modelado que se pueden discutir. La primera: «subtema» no llegó a ser una tabla. Es la misma entidad que «tema», con las mismas columnas y las mismas relaciones, y el tercer nivel que tienen los temarios oficiales habría pedido una tercera tabla. Un árbol con parent_id admite profundidad arbitraria; a cambio, recorrerlo en SQL es más incómodo que un join.
La segunda: la tabla de progreso del usuario no se creó. Es una tabla de agregados, o sea una caché, y una caché sin un problema de rendimiento medido añade una segunda fuente de verdad que se puede desincronizar con la primera. Los números no la piden: doscientas preguntas al día son unas 73.000 filas por usuario y año, y agrupar eso con un índice tarda milisegundos. Hay un umbral escrito para revisar la decisión —100 ms en el percentil 95 de la consulta del panel— en lugar de dejarla abierta.
La tercera: la numeración de un tema vive en la relación, no en el tema. «Derecho penal» es el Tema 3 en Mossos y el Tema 2 en Policía Local. Si el número fuese una columna del tema habría que elegir uno, incorrecto en todas las oposiciones menos una, o duplicar el tema con todas sus preguntas detrás. El código de temario está en la tabla pivote.
Implementación
Los invariantes están en el motor y no en el código de la aplicación. Una regla que solo vive en la aplicación se salta desde una importación masiva, desde un seeder o desde una consola de producción.
Un índice único parcial sobre answer_options (question_id) where is_correct hace imposible que una pregunta tenga dos respuestas correctas. Ese estado corrupto no da ningún síntoma: la aplicación corregiría mal en silencio y falsearía las estadísticas de quien estudia, y es el fallo de datos más caro de detectar a posteriori.
Una restricción CHECK impide publicar una pregunta generada por un modelo de lenguaje sin revisor asignado. Eso convierte lo que era una intención escrita en un documento de decisión en una garantía que un job con un fallo lógico no puede saltarse.
Corregir ocurre en el servidor, y la respuesta correcta no sale hacia fuera antes de contestar. Hay dos clases de recurso —una para contestar y otra para revisar— en lugar de una con un parámetro «incluir solución», porque ese parámetro acaba pasándose mal desde algún sitio y el fallo sería silencioso: nadie ve un JSON de más, se ve una aplicación que funciona. Once tests recorren el cuerpo entero de la respuesta buscando la solución, no una clave concreta.
Un intento de respuesta es un hecho ocurrido: no tiene updated_at y guarda si se acertó en lugar de deducirlo al leer. Deducirlo significaría que corregir una pregunta mal redactada cambia retroactivamente el pasado de todo el que ya la contestó, y con él las estadísticas que le dicen si va preparado.
Las opciones se barajan de forma estable por usuario y pregunta. Si quien redacta —o el modelo que genera— tiende a escribir la correcta en primer lugar, devolverlas ordenadas por identificador la delata sin que ninguna respuesta contenga la solución; y si el orden cambiara en cada recarga, la opción que alguien tenía a medio leer saltaría de sitio.
El algoritmo de repetición espaciada está escrito como función pura: sin base de datos debajo, sin contenedor de dependencias y sin reloj propio —el instante entra por parámetro y el aleatorizador por constructor—. Eso es lo que permite simular diez mil repasos encadenados y comprobar los invariantes después de cada uno. Con la misma lógica dentro de un controlador, esa prueba serían diez mil peticiones HTTP y no la escribiría nadie.
Escribir esa simulación corrigió el documento de diseño en dos sitios. El documento justificaba el desplazamiento aleatorio de las fechas diciendo que sin él la carga diaria sería «cero durante seis días y doscientas el séptimo». Medido, el pico peor pasa de 1.91 a 1.62 veces la mediana de su vecindad: el desplazamiento mejora, pero el argumento asumía que las preguntas introducidas el mismo día reciben la misma secuencia de calificaciones, y no la reciben. Se queda, porque mejora y no cuesta nada, pero la afirmación fuerte no era cierta y está corregida con los números.
La segunda corrección fue del propio test. La primera versión comparaba cada día con la mediana de los ciento ochenta y denunciaba un pico de seis veces que no existía: la carga decae —el corpus deja de crecer y los intervalos se alargan—, así que estaba midiendo la pendiente y no los picos. Un umbral ajustado a esa medición mal planteada habría quedado tan flojo que el test no habría vuelto a detectar nada.
Los tests corren contra PostgreSQL 17 real y no sobre SQLite en memoria, precisamente porque SQLite no tiene ninguna de esas dos capacidades. Allí, el test que comprueba que una pregunta no puede tener dos correctas pasaría en verde sin haber comprobado nada — y un test que da confianza sin probar nada es peor que no tenerlo.
Estado
Cinco fases de diez. Hechas: el esqueleto de Laravel 13 sobre PostgreSQL y Redis en Docker con integración continua, la autenticación con Sanctum y sus roles y políticas, el catálogo de contenido —oposiciones, temas, fuentes, preguntas, opciones y etiquetas— con un test por cada invariante que comprueba que dispara de verdad, el registro de respuestas y el planificador de repasos. 123 tests, de los cuales 91 corren contra PostgreSQL real, y PHPStan en nivel 6 sin errores.
Sin hacer: la interfaz de estudio, las sesiones, los simulacros cronometrados y el panel de revisión editorial. No está desplegado y no se puede visitar.
El seeder tampoco siembra preguntas, y es deliberado: una pregunta inventada para rellenar es contenido incorrecto con apariencia de correcto, que es justo lo que este proyecto existe para evitar. Los temas y las cinco normas sí son reales, y se siembran sin verificar, porque «sin verificar» y «verificada y correcta» no son lo mismo.