simbiot-harness/tests/test_tick_latency_noise.py
Simbiot Harness b257c32558 аудит @juno42bot 2026-10-16: медиа без подписи в триаже, мин-тик по входящему, тишина сек-уровня, изоляция тестов
- фото/документ/видео без подписи выпадали из триажа (_pending_inbox и
  build_context пускали только текст/голос): висели до 23 ч, все 4
  неотвеченных за неделю — такие. Один фильтр _is_pending, медиа с тегом
  и путём файла; окно свежести SIMBIOT_MEDIA_PENDING_H=6 — старые не
  будят LLM задним числом; стикер без текста — не повод.
- MinNudge: мин-тик через 3 с тишины после MessageReceived/Transcribed, не
  ждём границы минуты (было: медиана 36 с до старта LLM, p90 93 с при
  24 с самой модели). Слот следующего такта, граница его пропускает;
  SIMBIOT_MIN_NUDGE=0 — выключить.
- сек-уровень: CycleTicked+ReflexObserved каждую секунду = 1.35 млн
  подписанных событий/нед (98% стора 1.37 ГБ). Теперь heartbeat раз в
  60 с + такты с событием (алерт, команды, каналы); «reflex ok» не
  спамит журнал.
- 6 тестов писали в боевой стор под SIMBIOT_HOME тика (TTFTWatchdogRetry
  с бюджетом 0.5 с в проде — это они): изоляция SIMBIOT_DB.
Тест: tests/test_tick_latency_noise.py 20 PASS; полный набор — без регрессий.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:32:15 +00:00

124 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""Аудит 2026-10-16: медиа без подписи в триаже, тик по входящему, тишина сек-уровня.
Запуск: ./venv/bin/python tests/test_tick_latency_noise.py (hermetic, свой стор)
Проверяет: фото/документ/видео без подписи — ожидают ответа (свежие ≤ 6 ч),
старые — нет; стикер без текста — нет; команда — нет; контекст тика
показывает тег медиа с путём файла; MinNudge срабатывает после debounce на
MessageReceived/MessageTranscribed, игнорирует прочие события, выключается
env; сек-уровень пишет CycleTicked/ReflexObserved только heartbeat'ом и в
тактах с событием (команды/алерт), а тихие такты — без записи в стор.
"""
import json
import os
import sys
import tempfile
import time
from datetime import datetime, timedelta, timezone
sys.path.insert(0, "/root/simbiot-harness")
os.environ["SIMBIOT_DB"] = os.path.join(tempfile.mkdtemp(prefix="simbiot_latnoise_"), "harness.db")
for _v in ("SIMBIOT_MIN_NUDGE", "SIMBIOT_MIN_NUDGE_DEBOUNCE", "SIMBIOT_MEDIA_PENDING_H",
"SIMBIOT_SEC_HEARTBEAT", "SIMBIOT_DECISION"):
os.environ.pop(_v, None)
from harness import es, scheduler
from harness.levels import LEVELS
es.init_db()
fails = []
def check(name, cond, extra=""):
print(("PASS" if cond else "FAIL"), name, ("| " + str(extra)) if not cond else "")
if not cond:
fails.append(name)
now = datetime.now(timezone.utc)
def msg(uid, kind="text", text="", age_h=0.0, **kw):
return {"update_id": uid, "kind": kind, "text": text, "replied": False, "ignored": False,
"ts": (now - timedelta(hours=age_h)).isoformat(), "chat_id": 1,
"first_name": "Alex", **kw}
# ── 1. медиа без подписи в триаже ──
inbox = {f"m-{m['update_id']}": m for m in [
msg(1, "photo", age_h=0.1, file_path="inbox/1.jpg"),
msg(2, "document", age_h=1, file_name="dogovor.pdf", file_path="inbox/dogovor.pdf"),
msg(3, "video", age_h=0.2),
msg(4, "photo", age_h=30), # старое, выпало до фикса
msg(5, "sticker", age_h=0.1),
msg(6, "text", "/статус", age_h=0.1),
msg(7, "text", "привет", age_h=50), # текст — как раньше, без окна свежести
msg(8, "voice", age_h=40), # голосовое — как раньше
msg(9, "photo", age_h=0.1, replied=True),
]}
ids = {m["update_id"] for m in scheduler._pending_inbox({"inbox": inbox})}
check("фото/документ/видео без подписи — ждут ответа", {1, 2, 3} <= ids, ids)
check("старое медиа (>6ч) — не будит LLM", 4 not in ids, ids)
check("стикер без текста — не ждёт", 5 not in ids, ids)
check("команда — не для LLM", 6 not in ids, ids)
check("текст и голосовое — как раньше", {7, 8} <= ids, ids)
check("отвеченное — нет", 9 not in ids, ids)
os.environ["SIMBIOT_MEDIA_PENDING_H"] = "48"
check("окно свежести настраивается env", 4 in {m["update_id"] for m in scheduler._pending_inbox({"inbox": inbox})})
os.environ.pop("SIMBIOT_MEDIA_PENDING_H")
sig = scheduler.collect_signals(LEVELS["min"], {"inbox": {"m-1": inbox["m-1"]}, "tasks": {}})
check("фото без подписи → critical-сигнал мин-уровня", any(s[1] == "unreplied_inbox" for s in sig), sig)
ctx = scheduler.build_context(LEVELS["min"], {"inbox": {"m-2": inbox["m-2"]}, "tasks": {},
"escalations": {}, "policy": {}}, batch=[])
check("контекст: тег документа с путём и «без подписи»",
"[document, файл: inbox/dogovor.pdf, без подписи]" in ctx, ctx[:400])
# ── 2. MinNudge ──
os.environ["SIMBIOT_MIN_NUDGE_DEBOUNCE"] = "0.3"
n = scheduler.MinNudge()
n.poll() # первый проход — только запоминает хвост
check("nudge: на старте не срабатывает", not n.due())
es.append_event("cycle", "noise:1", "DigestWritten", {"level": "min"})
n.poll()
check("nudge: прочие события не будят", n.pending_since is None)
es.append_event("tg", "m-777", "MessageReceived", {"update_id": 777, "text": "эй"})
n.poll()
check("nudge: входящее замечено, но debounce", n.pending_since is not None and not n.due())
time.sleep(0.35)
check("nudge: после debounce — пора", n.due())
n.fired()
check("nudge: fired сбрасывает", not n.due())
es.append_event("tg", "m-777", "MessageTranscribed", {"text": "расшифровка"})
n.poll(); time.sleep(0.35)
check("nudge: расшифровка голосового тоже будит", n.due())
os.environ["SIMBIOT_MIN_NUDGE"] = "0"
n2 = scheduler.MinNudge(); n2.poll()
es.append_event("tg", "m-778", "MessageReceived", {"update_id": 778, "text": "эй"})
n2.poll(); time.sleep(0.35)
check("nudge: SIMBIOT_MIN_NUDGE=0 выключает", not n2.due())
os.environ.pop("SIMBIOT_MIN_NUDGE")
# ── 3. сек-уровень: heartbeat + такты с событием ──
def count(et):
return sum(1 for e in es.get_events(0, 100000) if e["event_type"] == et)
sec = LEVELS["sec"]
scheduler._SEC_HB.update(last=None, seq=None)
base_t, base_r = count("CycleTicked"), count("ReflexObserved")
start = 1_000_000
for i in range(130):
scheduler.process_level(sec, {"inbox": {}, "tasks": {}, "policy": {"cpu_alert_threshold": 1e9}},
seq=start + i)
dt, dr = count("CycleTicked") - base_t, count("ReflexObserved") - base_r
check("сек: 130 тихих тактов → 3 heartbeat CycleTicked (0, 60, 120)", dt == 3, dt)
check("сек: ReflexObserved только heartbeat", dr == 3, dr)
scheduler.process_level(sec, {"inbox": {}, "tasks": {}, "policy": {"cpu_alert_threshold": 0}},
seq=start + 131)
check("сек: алерт нагрузки пишется вне heartbeat", count("ReflexObserved") - base_r == 4)
last = [json.loads(e["data"]) for e in es.get_events(0, 100000)
if e["event_type"] == "ReflexObserved"][-1]
check("сек: в записи алерта есть alert", "alert" in last, last)
print(f"\n{'OK' if not fails else 'FAILED'}: {len(fails)} fail(s)")
sys.exit(1 if fails else 0)