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

@@ -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,