fix: el jitter era demasiado regular; planificar por adelantado

El 96% de las entradas caian en solo dos minutos (08:58 / 08:59). El historial
real del usuario tiene 4 valores repartidos, o sea que el bot resultaba MAS
regular que una persona.

Tres cosas estaban mal:

- La deriva de la costumbre se aplicaba SUMANDO segundos. Cuando salia negativa
  hundia el valor contra el suelo y amontonaba medio año en el mismo minuto,
  justo lo contrario de lo que pretendia. Ahora va en escala logaritmica:
  multiplica el centro en vez de sumarse. Solo con eso la moda baja del 50% al
  39%, sin coste ninguno.

- La distribucion diaria era uniforme. Una lognormal da mas minutos distintos
  por menos presencia extra, porque la mayoria de los dias caen baratos cerca de
  la hora y solo unos pocos se van lejos. Ademas es la forma real de la llegada
  de alguien que intenta ser puntual.

- El tope recortaba con min(), asi que un 3% de los dias caia exactamente en el
  limite y se veia como un muro en el histograma. Ahora resortea.

El compromiso de fondo no tiene truco: solo se puede variar hacia atras, y
alejarse cuesta presencia. Perfiles medidos sobre los 226 dias laborables del
año, los tres con saldo 0:

    discreto    2,8 min/dia   entrada 2 minutos, moda 75%
    natural     4,2 min/dia   entrada 5 minutos, moda 51%   <- por defecto
    camuflado   5,5 min/dia   entrada 4 minutos, moda 43%

Ademas, /proximos mostraba una banda estimada que ocultaba la cola. Ahora se
planifican 21 dias por adelantado y se muestran las horas exactas, con lo que
la prevision es real y se puede decidir sobre ella. El plan guardado se
revalida contra el calendario de Timenet y se regenera si cambia el tipo de
jornada de un dia. Google Calendar se consulta con una sola autenticacion para
todo el horizonte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 19:36:38 +02:00
parent 365f676a1f
commit a496b1c672
5 changed files with 133 additions and 87 deletions

View File

@@ -13,6 +13,8 @@ from telegram import (BotCommand, BotCommandScopeChat, InlineKeyboardButton,
from telegram.constants import ParseMode
from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes
from app import jitter
logger = logging.getLogger(__name__)
ENTRADA = 0
@@ -234,57 +236,43 @@ class BotUI:
async def cmd_proximos(self, update: Update, ctx) -> None:
try:
ndias = max(1, min(60, int(ctx.args[0]))) if ctx.args else 14
ndias = max(1, min(45, int(ctx.args[0]))) if ctx.args else 14
except ValueError:
ndias = 14
cal = await asyncio.to_thread(self.svc.executor.calendar)
j = self.svc.cfg["jitter"]
hoy = self.svc.today()
icon = {"pending": "⏳", "done": "✅", "cancelled": "🛑",
"skipped": "⤵️", "failed": "🟥"}
lines = [f"<b>Previsión · próximos {ndias} días</b>"]
laborables = 0
for i in range(ndias):
d = hoy + timedelta(days=i)
cabecera = f"<b>{fecha_corta(d)}</b>"
cab = f"<b>{fecha_corta(d)}</b>"
ov = self.svc.store.get_override(d)
if ov and ov["action"] == "off":
lines.append(f"{cabecera} 🚫 <i>{ov['reason'] or 'desactivado a mano'}</i>")
lines.append(f"{cab} 🚫 <i>{ov['reason'] or 'desactivado a mano'}</i>")
continue
if not cal.is_workday(d):
lines.append(f"{cabecera} 🌴 <i>{cal.why_not_workday(d)}</i>")
lines.append(f"{cab} 🌴 <i>{cal.why_not_workday(d)}</i>")
continue
laborables += 1
if d == hoy and self.svc.store.get_plan(d):
icon = {"pending": "⏳", "done": "✅", "cancelled": "🛑",
"skipped": "⤵️", "failed": "🟥"}
trozos = []
for r in self.svc.store.get_plan(d):
t = datetime.fromisoformat(r["planned_at"])
trozos.append(f"{icon.get(r['state'], '·')}{t:%H:%M:%S}")
lines.append(f"{cabecera} {' '.join(trozos)}")
continue
# dias futuros: el instante exacto se sortea esa madrugada, pero el
# rango en que caera si es exacto
trozos = []
for w in cal.windows(d):
ini = datetime.combine(d, datetime.min.time())
ent = ini + timedelta(minutes=w.start)
sal = ini + timedelta(minutes=w.end)
trozos.append(
f"{ent - timedelta(seconds=j['entry_before_max']):%H:%M}"
f"-{ent - timedelta(seconds=j['entry_before_min']):%H:%M}"
f" → {sal + timedelta(seconds=j['exit_after_min']):%H:%M}"
f"-{sal + timedelta(seconds=j['exit_after_max']):%H:%M}")
lines.append(f"{cabecera} <code>{' · '.join(trozos)}</code>")
plan = self.svc.store.get_plan(d)
if plan:
trozos = [f"{icon.get(r['state'], '·')}"
f"{datetime.fromisoformat(r['planned_at']):%H:%M:%S}"
for r in plan]
lines.append(f"{cab} <code>{' '.join(trozos)}</code>")
else:
# mas alla del horizonte que se planifica por adelantado
tramos = " ".join(str(w) for w in cal.windows(d))
lines.append(f"{cab} <code>{tramos}</code> <i>(sin planificar aún)</i>")
b = await asyncio.to_thread(self.svc.executor.balance)
lines.append(f"\n{laborables} días laborables · saldo acumulado <b>{hm(b['total'])}</b>")
lines.append("<i>Las horas futuras son el rango en que caerá el fichaje; "
"el segundo exacto se sortea esa madrugada.</i>")
await update.effective_message.reply_text("\n".join(lines),
parse_mode=ParseMode.HTML)