File size: 4,035 Bytes
9a08963
5f80ef1
9a08963
5f80ef1
9a08963
 
 
 
 
5f80ef1
9a08963
 
 
 
5f80ef1
9a08963
 
5f80ef1
5c09528
 
9a08963
 
 
 
5f80ef1
9a08963
 
 
 
5f80ef1
9a08963
 
 
 
5f80ef1
9a08963
 
 
 
 
 
 
 
0b65b92
 
9a08963
 
 
 
 
 
 
 
 
 
 
5c09528
 
 
5f80ef1
5c09528
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
"""
lumen_typing_pace.py — самокалибрующийся темп "печати" при стриминге в Telegram.

Скорость — свойство бэкенда под слагом, а не модели, поэтому замер по факту + EMA. Единица — символы/с. EMA только в памяти.
"""

from __future__ import annotations

# ── границы скорости печати (символов/сек) ──
# Подобраны под ощущение живого набора, владелец может менять прямо здесь.
DEFAULT_CHARS_PER_SEC = 90.0
MIN_CHARS_PER_SEC = 40.0
MAX_CHARS_PER_SEC = 260.0

# Меньше альфа — стабильнее оценка, но медленнее подстройка под смену бэкенда под тем же слагом.
_EMA_ALPHA = 0.3

# Выше, чем у EMA всего ответа: темп прихода меняется внутри одного стрима.
_ARRIVAL_ALPHA = 0.5

_speed_ema: dict[str, float] = {}


def speed_key(provider: str, model_id: str) -> str:
    """Единый ключ пары (provider, model_id), как в GLOBAL_QUOTA."""
    return f"{provider}:{model_id}"


def get_typing_speed(key: str) -> float:
    """Оценка для модели или дефолт без замеров. Результат всегда в границах скорости."""
    return max(MIN_CHARS_PER_SEC, min(MAX_CHARS_PER_SEC, _speed_ema.get(key, DEFAULT_CHARS_PER_SEC)))


def record_observed_speed(key: str, elapsed_sec: float, chars_len: int) -> None:
    """Учесть один завершённый стрим, вызывать раз в конце. elapsed_sec — без фазы довывода, иначе EMA поверит своим же задержкам. Наблюдение зажимается до усреднения, иначе один выброс утащит EMA."""
    if elapsed_sec <= 0 or chars_len <= 0:
        return
    observed = max(MIN_CHARS_PER_SEC, min(MAX_CHARS_PER_SEC, chars_len / elapsed_sec))
    prev = _speed_ema.get(key)
    _speed_ema[key] = observed if prev is None else (_EMA_ALPHA * observed + (1 - _EMA_ALPHA) * prev)


def catchup_reveal_steps(remaining_len: int, chars_per_sec: float, tick_interval_sec: float, max_ticks: int) -> list[int]:
    """Довывод остатка шагами; хвост добирается форсированно, задержка не больше max_ticks × tick.
    Без обращения к часам: в тестах ожидание подменено no-op, цикл на monotonic завис бы."""
    if remaining_len <= 0:
        return []
    chars_per_tick = max(1, int(chars_per_sec * tick_interval_sec))
    steps: list[int] = []
    revealed = 0
    while revealed < remaining_len and len(steps) < max_ticks:
        revealed = min(remaining_len, revealed + chars_per_tick)
        steps.append(revealed)
    if steps and steps[-1] < remaining_len:
        steps[-1] = remaining_len
    return steps


def blend_arrival_speed(prev_ewma: float | None, piece_len: int, dt_sec: float) -> float | None:
    """Подмешивает кусок в темп прихода. Нулевая дельта не наблюдение, возвращаем прежнее."""
    if piece_len <= 0 or dt_sec <= 0:
        return prev_ewma
    inst = max(MIN_CHARS_PER_SEC, min(MAX_CHARS_PER_SEC, piece_len / dt_sec))
    return inst if prev_ewma is None else _ARRIVAL_ALPHA * inst + (1 - _ARRIVAL_ALPHA) * prev_ewma


def display_speed_for(arrival_ewma: float | None, pace_key: str) -> float:
    """Скорость показа: измеренный темп прихода, пока он есть; иначе глобальная EMA модели (затравка на первые куски)."""
    if arrival_ewma is not None:
        return max(MIN_CHARS_PER_SEC, min(MAX_CHARS_PER_SEC, arrival_ewma))
    return get_typing_speed(pace_key)