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.constants import ParseMode
from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes
from app import jitter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ENTRADA = 0 ENTRADA = 0
@@ -234,57 +236,43 @@ class BotUI:
async def cmd_proximos(self, update: Update, ctx) -> None: async def cmd_proximos(self, update: Update, ctx) -> None:
try: 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: except ValueError:
ndias = 14 ndias = 14
cal = await asyncio.to_thread(self.svc.executor.calendar) cal = await asyncio.to_thread(self.svc.executor.calendar)
j = self.svc.cfg["jitter"]
hoy = self.svc.today() hoy = self.svc.today()
icon = {"pending": "⏳", "done": "✅", "cancelled": "🛑",
"skipped": "⤵️", "failed": "🟥"}
lines = [f"<b>Previsión · próximos {ndias} días</b>"] lines = [f"<b>Previsión · próximos {ndias} días</b>"]
laborables = 0 laborables = 0
for i in range(ndias): for i in range(ndias):
d = hoy + timedelta(days=i) 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) ov = self.svc.store.get_override(d)
if ov and ov["action"] == "off": 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 continue
if not cal.is_workday(d): 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 continue
laborables += 1 laborables += 1
if d == hoy and self.svc.store.get_plan(d): plan = self.svc.store.get_plan(d)
icon = {"pending": "⏳", "done": "✅", "cancelled": "🛑", if plan:
"skipped": "⤵️", "failed": "🟥"} trozos = [f"{icon.get(r['state'], '·')}"
trozos = [] f"{datetime.fromisoformat(r['planned_at']):%H:%M:%S}"
for r in self.svc.store.get_plan(d): for r in plan]
t = datetime.fromisoformat(r["planned_at"]) lines.append(f"{cab} <code>{' '.join(trozos)}</code>")
trozos.append(f"{icon.get(r['state'], '·')}{t:%H:%M:%S}") else:
lines.append(f"{cabecera} {' '.join(trozos)}") # mas alla del horizonte que se planifica por adelantado
continue tramos = " ".join(str(w) for w in cal.windows(d))
lines.append(f"{cab} <code>{tramos}</code> <i>(sin planificar aún)</i>")
# 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>")
b = await asyncio.to_thread(self.svc.executor.balance) 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(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), await update.effective_message.reply_text("\n".join(lines),
parse_mode=ParseMode.HTML) parse_mode=ParseMode.HTML)

View File

@@ -27,6 +27,7 @@ logger = logging.getLogger(__name__)
ANNOUNCE_LEAD = timedelta(minutes=12) # cuanta antelacion tiene el killswitch ANNOUNCE_LEAD = timedelta(minutes=12) # cuanta antelacion tiene el killswitch
MAX_SLEEP = 60.0 MAX_SLEEP = 60.0
REPLAN_HOUR = 4 # hora a la que se planifica el dia REPLAN_HOUR = 4 # hora a la que se planifica el dia
HORIZON_DAYS = 21 # dias que se planifican por adelantado
class Service: class Service:
@@ -41,6 +42,7 @@ class Service:
self._wake = asyncio.Event() self._wake = asyncio.Event()
self._stop = asyncio.Event() self._stop = asyncio.Event()
self._planned_for: date | None = None self._planned_for: date | None = None
self._gcal = None
# ------------------------------------------------------------------ basico # ------------------------------------------------------------------ basico
def now(self) -> datetime: def now(self) -> datetime:
@@ -78,7 +80,9 @@ class Service:
return None return None
try: try:
from app import google_calendar 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"): if ev and isinstance(ev, str) and not ev.startswith("Error"):
return ev return ev
if ev and str(ev).startswith("Error"): if ev and str(ev).startswith("Error"):
@@ -89,43 +93,70 @@ class Service:
return None return None
# --------------------------------------------------------------------- plan # --------------------------------------------------------------------- plan
async def ensure_plan(self, force: bool = False) -> list: def _plan_al_dia(self, day: date, cal) -> bool:
day = self.today() """El plan guardado sigue cuadrando con el calendario de Timenet."""
if self._planned_for == day and not force: rows = self.store.get_plan(day)
return self.store.get_plan(day) if not rows:
return False
cal = await asyncio.to_thread(self.executor.calendar) esperado = [m for w in cal.windows(day) for m in (w.start, w.end)]
self._planned_for = day 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(): if self.is_paused():
logger.info("servicio en pausa, no se planifica")
return [] return []
ov = self.store.get_override(day) ov = self.store.get_override(day)
if ov and ov["action"] == "off": if ov and ov["action"] == "off":
logger.info("%s desactivado a mano: %s", day, ov["reason"])
return [] return []
if not cal.is_workday(day): if not cal.is_workday(day):
motivo = cal.why_not_workday(day) if day == self.today():
logger.info("%s no laborable: %s", day, motivo) self.store.log("plan", f"{day} no laborable: {cal.why_not_workday(day)}")
self.store.log("plan", f"{day} no laborable: {motivo}")
return [] 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) ausencia = await self._personal_absence(day)
if ausencia: if ausencia:
self.store.set_override(day, "off", f"Google Calendar: {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 [] return []
plan = self.planner.plan_day(cal, day, random.Random()) plan = self.planner.plan_day(cal, day, random.Random())
self.store.save_plan(day, self.planner.rows_for_store(plan)) 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]) logger.info("plan de %s: %s", day, [str(p) for p in plan])
self.wake()
return self.store.get_plan(day) 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: async def cancel_pending_today(self, reason: str) -> None:
day = self.today() day = self.today()
for r in self.store.pending(day): for r in self.store.pending(day):
@@ -185,7 +216,7 @@ class Service:
async def tick(self) -> None: async def tick(self) -> None:
now = self.now() now = self.now()
if self._planned_for != now.date(): if self._planned_for != now.date():
await self.ensure_plan() await self.ensure_horizon()
if self.is_paused(): if self.is_paused():
return 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 * 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 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. 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 * Esa restriccion es asimetrica: solo se puede variar hacia el lado seguro, y
se suma el ruido del dia. 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 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 # reversion a la media de la costumbre diaria: 0 = sin memoria, 1 = no se mueve
DRIFT_MEMORY = 0.82 DRIFT_MEMORY = 0.82
# probabilidad de un dia atipico (te despistas y llegas bastante antes / te quedas mas) # z de la normal tipificada para el percentil 10/90, usado al describir el rango
OUTLIER_P = 0.06 Z90 = 1.2816
def next_drift(previous: float, sigma: float, clamp: float, rng: random.Random) -> float: 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)) return max(-clamp, min(clamp, nxt))
def offset_seconds(lo: int, hi: int, drift: float, rng: random.Random, def offset_seconds(floor: int, median: float, sigma: float, cap: int,
outlier_cap: int | None = None) -> int: drift: float, rng: random.Random) -> int:
"""Desplazamiento en segundos respecto de la hora teorica, siempre en el lado seguro. """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 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 se ficha una salida. Lognormal desplazada, con la moda cerca de `median` y la
hacia el lado que cuesta dinero. 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 centro = max(1.0, median - floor) * math.exp(drift)
spread = max(1.0, (hi - lo) / 4.0) # resorteo en vez de recorte: truncar contra el tope amontonaba un 3% de los
val = mid + drift + rng.gauss(0.0, spread) # 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, 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) 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) 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( off = jitter.offset_seconds(
self.j["entry_before_min"], self.j["entry_before_max"], self.j["entry_floor"], self.j["entry_median"], self.j["entry_sigma"],
self._drift(wi, ENTRADA, rng), rng, tope, 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)),
)
off = max(off, self.j["entry_guard"]) off = max(off, self.j["entry_guard"])
out.append(PlannedCheck(idx, ENTRADA, wi, start, start - timedelta(seconds=off))) out.append(PlannedCheck(idx, ENTRADA, wi, start, start - timedelta(seconds=off)))
idx += 1 idx += 1
# SALIDA: siempre despues # SALIDA: siempre despues
off = jitter.offset_seconds( off = jitter.offset_seconds(
self.j["exit_after_min"], self.j["exit_after_max"], self.j["exit_floor"], self.j["exit_median"], self.j["exit_sigma"],
self._drift(wi, SALIDA, rng), rng, self.j["exit_cap"], self._drift(wi, SALIDA, rng), rng)
outlier_cap=self.j["exit_after_max"] * 4,
)
out.append(PlannedCheck(idx, SALIDA, wi, end, end + timedelta(seconds=off))) out.append(PlannedCheck(idx, SALIDA, wi, end, end + timedelta(seconds=off)))
idx += 1 idx += 1

View File

@@ -19,18 +19,28 @@ DEFAULTS = {
# margen, en segundos, con el que se ficha respecto de la hora teorica. # 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 # Entradas ANTES (Timenet no acredita nada fuera de la ventana, pero si
# debita las entradas tardias). Salidas DESPUES. # debita las entradas tardias). Salidas DESPUES.
# perfil "equilibrado": saldo 0 con ~3,6 min/dia de presencia extra. # Lognormal: la moda queda pegada a la hora teorica y la cola va hacia
# Mas holgado (40/150/30/130) = mas seguro pero ~5,7 min/dia. # mas margen. Da mas minutos distintos por menos presencia extra que una
# Mas ajustado (20/70/15/45) = ~2,7 min/dia pero solo 15s de colchon. # uniforme (ver la nota de cabecera de app/jitter.py).
"entry_before_min": 30, # floor = margen minimo, median = mediana, sigma = longitud de la cola,
"entry_before_max": 100, # cap = tope duro.
"exit_after_min": 20, # perfil "natural": 5 minutos distintos en la entrada, 4,1 min/dia de
"exit_after_max": 70, # presencia extra. Ver la tabla de perfiles en el README.
# deriva lenta de la hora habitual, para no parecer un dado nuevo cada dia "entry_floor": 12,
"drift_sigma": 25, "entry_median": 58,
"drift_clamp": 60, "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 # 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, "geo_jitter_meters": 12.0,
"telegram": {}, "telegram": {},