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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user