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)

View File

@@ -27,6 +27,7 @@ logger = logging.getLogger(__name__)
ANNOUNCE_LEAD = timedelta(minutes=12) # cuanta antelacion tiene el killswitch
MAX_SLEEP = 60.0
REPLAN_HOUR = 4 # hora a la que se planifica el dia
HORIZON_DAYS = 21 # dias que se planifican por adelantado
class Service:
@@ -41,6 +42,7 @@ class Service:
self._wake = asyncio.Event()
self._stop = asyncio.Event()
self._planned_for: date | None = None
self._gcal = None
# ------------------------------------------------------------------ basico
def now(self) -> datetime:
@@ -78,7 +80,9 @@ class Service:
return None
try:
from app import google_calendar
ev = await asyncio.to_thread(lambda: google_calendar.GoogleCalendar().getEvent(day))
if self._gcal is None: # una sola autenticacion para todo el horizonte
self._gcal = await asyncio.to_thread(google_calendar.GoogleCalendar)
ev = await asyncio.to_thread(self._gcal.getEvent, day)
if ev and isinstance(ev, str) and not ev.startswith("Error"):
return ev
if ev and str(ev).startswith("Error"):
@@ -89,43 +93,70 @@ class Service:
return None
# --------------------------------------------------------------------- plan
async def ensure_plan(self, force: bool = False) -> list:
day = self.today()
if self._planned_for == day and not force:
return self.store.get_plan(day)
cal = await asyncio.to_thread(self.executor.calendar)
self._planned_for = day
def _plan_al_dia(self, day: date, cal) -> bool:
"""El plan guardado sigue cuadrando con el calendario de Timenet."""
rows = self.store.get_plan(day)
if not rows:
return False
esperado = [m for w in cal.windows(day) for m in (w.start, w.end)]
real = []
for r in rows:
t = datetime.fromisoformat(r["target"])
real.append(t.hour * 60 + t.minute)
return real == esperado
async def _plan_un_dia(self, day: date, cal, force: bool = False) -> list:
"""Planifica un dia concreto. Devuelve las filas guardadas."""
if self.is_paused():
logger.info("servicio en pausa, no se planifica")
return []
ov = self.store.get_override(day)
if ov and ov["action"] == "off":
logger.info("%s desactivado a mano: %s", day, ov["reason"])
return []
if not cal.is_workday(day):
motivo = cal.why_not_workday(day)
logger.info("%s no laborable: %s", day, motivo)
self.store.log("plan", f"{day} no laborable: {motivo}")
if day == self.today():
self.store.log("plan", f"{day} no laborable: {cal.why_not_workday(day)}")
return []
# ya planificado y el calendario no ha cambiado
if not force and self._plan_al_dia(day, cal):
return self.store.get_plan(day)
ausencia = await self._personal_absence(day)
if ausencia:
self.store.set_override(day, "off", f"Google Calendar: {ausencia}")
await self.notify(f"🌴 Hoy no se ficha: <i>{ausencia}</i> (Google Calendar)")
if day == self.today():
await self.notify(f"🌴 Hoy no se ficha: <i>{ausencia}</i> (Google Calendar)")
return []
plan = self.planner.plan_day(cal, day, random.Random())
self.store.save_plan(day, self.planner.rows_for_store(plan))
self.store.log("plan", f"{day} planificado: " +
", ".join(f"{p.label[:3]} {p.planned_at:%H:%M:%S}" for p in plan))
logger.info("plan de %s: %s", day, [str(p) for p in plan])
self.wake()
return self.store.get_plan(day)
async def ensure_horizon(self, dias: int = HORIZON_DAYS, force: bool = False) -> None:
"""Planifica hoy y los proximos dias, para que la prevision sea exacta."""
cal = await asyncio.to_thread(self.executor.calendar)
hoy = self.today()
nuevos = 0
for i in range(dias):
d = hoy + timedelta(days=i)
antes = bool(self.store.get_plan(d))
await self._plan_un_dia(d, cal, force=force and i == 0)
if not antes and self.store.get_plan(d):
nuevos += 1
self._planned_for = hoy
if nuevos:
self.store.log("plan", f"planificados {nuevos} dias nuevos hasta "
f"{hoy + timedelta(days=dias - 1)}")
self.wake()
async def ensure_plan(self, force: bool = False) -> list:
"""Plan de hoy. `force` lo vuelve a tirar aunque ya exista."""
await self.ensure_horizon(force=force)
return self.store.get_plan(self.today())
async def cancel_pending_today(self, reason: str) -> None:
day = self.today()
for r in self.store.pending(day):
@@ -185,7 +216,7 @@ class Service:
async def tick(self) -> None:
now = self.now()
if self._planned_for != now.date():
await self.ensure_plan()
await self.ensure_horizon()
if self.is_paused():
return

View File

@@ -5,9 +5,18 @@ Reglas del juego (ver app/schedule.py y el analisis del historico):
* Timenet solo cuenta el tiempo DENTRO de la ventana teorica. Una entrada tardia se
debita al minuto; una salida tardia no acredita nada. Por tanto las ENTRADAS se
fichan siempre ANTES de la hora y las SALIDAS siempre DESPUES. Nunca al reves.
* Para que no parezca un dado nuevo cada dia, cada hueco del horario tiene su propia
"costumbre" que deriva lentamente (proceso de reversion a la media) y sobre la que
se suma el ruido del dia.
* Esa restriccion es asimetrica: solo se puede variar hacia el lado seguro, y
alejarse cuesta presencia real. Una distribucion uniforme resuelve mal ese
compromiso: para que se vean 4 minutos distintos hay que pagar 2,1 min de media.
Una LOGNORMAL da mas variedad por menos coste (5 minutos por 1,9) porque la
mayoria de los dias caen baratos, cerca de la hora, y solo unos pocos se van
lejos. Ademas es la forma que tiene de verdad la llegada de una persona que
intenta ser puntual: moda pegada a la hora y cola hacia atras.
* Encima va la COSTUMBRE, que deriva despacio: la hora habitual de esta semana no
es la de la que viene. Eso ensancha lo que se ve a lo largo del mes sin coste
diario, y es mas humano que tirar un dado nuevo e independiente cada dia.
"""
from __future__ import annotations
@@ -16,8 +25,8 @@ import random
# reversion a la media de la costumbre diaria: 0 = sin memoria, 1 = no se mueve
DRIFT_MEMORY = 0.82
# probabilidad de un dia atipico (te despistas y llegas bastante antes / te quedas mas)
OUTLIER_P = 0.06
# z de la normal tipificada para el percentil 10/90, usado al describir el rango
Z90 = 1.2816
def next_drift(previous: float, sigma: float, clamp: float, rng: random.Random) -> float:
@@ -26,24 +35,34 @@ def next_drift(previous: float, sigma: float, clamp: float, rng: random.Random)
return max(-clamp, min(clamp, nxt))
def offset_seconds(lo: int, hi: int, drift: float, rng: random.Random,
outlier_cap: int | None = None) -> int:
"""Desplazamiento en segundos respecto de la hora teorica, siempre en el lado seguro.
def offset_seconds(floor: int, median: float, sigma: float, cap: int,
drift: float, rng: random.Random) -> int:
"""Desplazamiento en segundos respecto de la hora teorica, en el lado seguro.
Devuelve un numero POSITIVO: cuanto antes se ficha una entrada, o cuanto despues
se ficha una salida. La cola larga va hacia el lado seguro (mas margen), nunca
hacia el lado que cuesta dinero.
se ficha una salida. Lognormal desplazada, con la moda cerca de `median` y la
cola hacia mas margen (nunca hacia menos).
`drift` va en unidades LOGARITMICAS, no en segundos: multiplica el centro en vez
de sumarse. Sumandolo, una deriva negativa lo hundia contra `floor` y amontonaba
la mitad de los dias en el mismo minuto, justo lo que se queria evitar.
"""
mid = (lo + hi) / 2.0
spread = max(1.0, (hi - lo) / 4.0)
val = mid + drift + rng.gauss(0.0, spread)
centro = max(1.0, median - floor) * math.exp(drift)
# resorteo en vez de recorte: truncar contra el tope amontonaba un 3% de los
# dias exactamente en el limite, que se ve como un muro en el histograma
for _ in range(8):
val = floor + centro * math.exp(rng.gauss(0.0, sigma))
if val <= cap:
return int(round(max(float(floor), val)))
return int(round(max(float(floor), min(float(cap), val))))
if rng.random() < OUTLIER_P:
cap = outlier_cap if outlier_cap is not None else hi * 3
val += abs(rng.gauss(0.0, (cap - hi) / 2.5)) if cap > hi else 0.0
val = min(val, cap)
return int(round(max(lo, val)))
def offset_band(floor: int, median: float, sigma: float, cap: int) -> tuple[int, int]:
"""Banda p10-p90 en segundos, para describir el rango sin prometer la cola."""
centro = max(1.0, median - floor)
lo = floor + centro * math.exp(-Z90 * sigma)
hi = floor + centro * math.exp(+Z90 * sigma)
return int(round(max(floor, lo))), int(round(min(float(cap), hi)))
def jitter_geo(latitude: float, longitude: float, meters: float,

View File

@@ -76,23 +76,21 @@ class Planner:
start = datetime.combine(day, datetime.min.time(), self.tz) + timedelta(minutes=w.start)
end = datetime.combine(day, datetime.min.time(), self.tz) + timedelta(minutes=w.end)
# ENTRADA: siempre antes de la hora teorica
# ENTRADA: siempre antes de la hora teorica. El tope se acota ademas
# con el margen de entrada que declara el propio tipo de jornada.
tope = min(self.j["entry_cap"], max(w.entry_margin * 60 // 2, 60)) if w.entry_margin \
else self.j["entry_cap"]
off = jitter.offset_seconds(
self.j["entry_before_min"], self.j["entry_before_max"],
self._drift(wi, ENTRADA, rng), rng,
# el margen de entrada del propio Timenet acota cuanto se puede adelantar
outlier_cap=min(self.j["entry_before_max"] * 3, max(w.entry_margin * 60 // 2, 60)),
)
self.j["entry_floor"], self.j["entry_median"], self.j["entry_sigma"],
tope, self._drift(wi, ENTRADA, rng), rng)
off = max(off, self.j["entry_guard"])
out.append(PlannedCheck(idx, ENTRADA, wi, start, start - timedelta(seconds=off)))
idx += 1
# SALIDA: siempre despues
off = jitter.offset_seconds(
self.j["exit_after_min"], self.j["exit_after_max"],
self._drift(wi, SALIDA, rng), rng,
outlier_cap=self.j["exit_after_max"] * 4,
)
self.j["exit_floor"], self.j["exit_median"], self.j["exit_sigma"],
self.j["exit_cap"], self._drift(wi, SALIDA, rng), rng)
out.append(PlannedCheck(idx, SALIDA, wi, end, end + timedelta(seconds=off)))
idx += 1

View File

@@ -19,18 +19,28 @@ DEFAULTS = {
# margen, en segundos, con el que se ficha respecto de la hora teorica.
# Entradas ANTES (Timenet no acredita nada fuera de la ventana, pero si
# debita las entradas tardias). Salidas DESPUES.
# perfil "equilibrado": saldo 0 con ~3,6 min/dia de presencia extra.
# Mas holgado (40/150/30/130) = mas seguro pero ~5,7 min/dia.
# Mas ajustado (20/70/15/45) = ~2,7 min/dia pero solo 15s de colchon.
"entry_before_min": 30,
"entry_before_max": 100,
"exit_after_min": 20,
"exit_after_max": 70,
# deriva lenta de la hora habitual, para no parecer un dado nuevo cada dia
"drift_sigma": 25,
"drift_clamp": 60,
# Lognormal: la moda queda pegada a la hora teorica y la cola va hacia
# mas margen. Da mas minutos distintos por menos presencia extra que una
# uniforme (ver la nota de cabecera de app/jitter.py).
# floor = margen minimo, median = mediana, sigma = longitud de la cola,
# cap = tope duro.
# perfil "natural": 5 minutos distintos en la entrada, 4,1 min/dia de
# presencia extra. Ver la tabla de perfiles en el README.
"entry_floor": 12,
"entry_median": 58,
"entry_sigma": 0.95,
"entry_cap": 330,
"exit_floor": 10,
"exit_median": 42,
"exit_sigma": 0.95,
"exit_cap": 300,
# Deriva lenta de la hora habitual, en unidades LOGARITMICAS (multiplica
# el centro). Mueve la moda de una semana a otra, lo que ensancha lo que se
# ve a lo largo del mes con coste medio practicamente nulo.
"drift_sigma": 0.30,
"drift_clamp": 0.75,
# margen de seguridad: nunca fichar una entrada a menos de X s de la hora
"entry_guard": 20,
"entry_guard": 12,
},
"geo_jitter_meters": 12.0,
"telegram": {},