simbiot-harness/tests/test_memory.py
Simbiot Harness 969d57f14a куб 4×4×4: 4 круга доверия, динамическая память D0–D3 с приватностью по кругу, голова depth
Куб Alex: круг доверия × тик (sec/min/hour/day) × глубина памяти —
одна таблица политик, не 64 программы.
- круги 4: 1 владелец (+система: тик без внешних входов), 2 правление,
  3 свои (портал, MCP-агенты, партнёры), 4 остальные; trust.norm для
  старых номеров (в сторе только 1/2 — смысл тот же); потолок памяти круга;
- harness/memory.py: индекс собственной истории (<home>/memory) — разговоры
  (сообщение + ответ; батч — ответ головы), свои рассылки (не ответы),
  итоги задач, дайджесты без заглушек; nomic search_document/query;
  КРУГ ПРИВАТНОСТИ записи = самый недоверенный участник чата (личка
  Alex — 1, «Правление» — 2, задача — её круг, дайджест — 1); поиск
  отдаёт только записи круга ≥ круга тика — тик с правлением не поднимет
  личку владельца; SIMBIOT_MEMORY=on|off (дефолт off — тесты герметичны);
- глубина: D0 односложные / D1 разговор / D2 + эпизоды (8) / D3 + архив
  (15): уровень (час D2, день D3), «помнишь/вчера/как договаривались» →
  D2, «за месяц/что делал/итоги» → D3, задачи → D2, reply на давнее (>48 ч)
  сообщение бота → D2; потолок круга (свои — D2, остальные — D1);
- DepthChosen по трассе (rag/копание в своём сторе/«не помню» = нехватка)
  → голова ядра depth (shadow; on — уверенный ученик поднимает до D2);
- выжимки нижних уровней: проекция не хранила stub — заглушки «LLM не
  запускался» забивали контекст; теперь отсеиваются;
- ядро: чтение чемпиона больше не создаёт каталоги, без чемпиона —
  без эмбеддингов (тесты создавали core/ в репо и ходили в Ollama).
Тесты: test_memory 30, test_trust 55 PASS; полный набор — без регрессий.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 10:43:12 +00:00

184 lines
11 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 -*-
"""Динамическая память Коди (harness/memory.py) — hermetic.
Запуск: ./venv/bin/python tests/test_memory.py
Эмбеддинги — «мешок слов» (общие слова → близость), Ollama не трогается.
Проверяет: память выключена по умолчанию; синк: разговоры (батч — ответ
головы), свои рассылки (не ответы), итоги задач, дайджесты без заглушек;
круг приватности записи (личка Alex — 1, правление — 2, задача — её круг);
поиск не отдаёт тику правления записи владельца; свежий разговор чата не
дублируется; глубина: D1/D0/D2/D3 по правилам, потолок круга, давний
reply; контекст: блок памяти на D2+, D0 без памяти разговора; заглушки
дайджестов не в выжимках; DepthChosen с признаком нехватки памяти.
"""
import hashlib
import json
import os
import re
import sys
import tempfile
from datetime import datetime, timedelta, timezone
sys.path.insert(0, "/root/simbiot-harness")
_tmp = tempfile.mkdtemp(prefix="simbiot_memory_")
os.environ["SIMBIOT_DB"] = os.path.join(_tmp, "harness.db")
os.environ["SIMBIOT_MEMORY_DIR"] = os.path.join(_tmp, "memory")
os.environ["SIMBIOT_CORE_DIR"] = os.path.join(_tmp, "core")
for _v in ("SIMBIOT_MEMORY", "SIMBIOT_DECISION"):
os.environ.pop(_v, None)
import numpy as np
from harness import es, kodi_core, memory, scheduler, trust
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)
def bow(texts, prefix="classification"):
out = []
for t in texts:
v = np.zeros(768, np.float32)
for w in re.findall(r"\w{4,}", t.lower()):
v[int(hashlib.md5(w.encode()).hexdigest(), 16) % 768] += 1
v += 1e-3
out.append(v / np.linalg.norm(v))
return out
kodi_core.embed = bow
check("память выключена по умолчанию", memory.enabled() is False)
os.environ["SIMBIOT_MEMORY"] = "on"
trust.bootstrap()
A, V, G = 7569223131, 156156021, -1004402779881
N = [0]
def msg(chat, uid, who, text, reply=None, batch=None):
N[0] += 1
mid = f"m-{N[0]}"
es.append_event("inbox", mid, "MessageReceived", {"update_id": N[0], "chat_id": chat,
"user_id": uid, "first_name": who, "text": text, "chat_type":
"private" if chat > 0 else "supergroup"})
if reply is not None:
d = {"to_chat": chat, "text": reply}
if batch:
d["batch"] = batch
es.append_event("inbox", mid, "ReplySent", d)
return mid
h = msg(A, A, "Alex", "секретный бюджет казны проекта альфа", "Бюджет альфа: 40 тысяч")
msg(G, V, "Vlad", "лендинг фильма мой срок дизайн обложки", "Сделаю лендинг фильма")
b1 = msg(A, A, "Alex", "проверь сервер миграцию", "Сервер мигрирован, всё ок",
batch={"head": None})
b2 = msg(A, A, "Alex", "и бэкапы тоже", "(сводный ответ батча b-1 — см. " + b1 + ")",
batch={"head": b1})
es.append_event("outbound", f"{A}:1", "OutboundMessage", {"chat_id": A, "message_id": 1,
"text": "🧠 Предложение: включить ученика адресации"})
es.append_event("outbound", f"{A}:2", "OutboundMessage", {"chat_id": A, "message_id": 2,
"text": "Сервер мигрирован, всё ок"}) # это ответ — уже в conv
es.append_event("task", "t-1", "TaskSubmitted", {"id": "t-1", "title": "Обложка фильма мой срок",
"size": "M", "circle": 2})
es.append_event("task", "t-1", "TaskCompleted", {"answer": "Обложка фильма готова: out/cover.png"})
es.append_event("cycle", "digest:hour:1", "DigestWritten", {"level": "hour", "period": "p1",
"text": "Час: обсудили бюджет проекта альфа, сколько там денег и сроки"})
es.append_event("cycle", "digest:hour:2", "DigestWritten", {"level": "hour", "period": "p2",
"text": "[gate:quiet] hour: LLM не запускался", "stub": True})
n = memory.sync_all()
st = memory.stats()
check("синк: разговоры/рассылка/задача/дайджест", st["items"] == {"conv": 4, "out": 1, "task": 1, "digest": 1}, st)
db = memory._db()
items = {r[0]: r for r in db.execute("SELECT id, kind, circle, text FROM items")}
db.close()
check("ответ батча — текст головы", "Сервер мигрирован" in items[f"conv:{b2}"][3])
check("рассылка-ответ не дублируется как out", not any(
r[1] == "out" and "мигрирован" in r[3] for r in items.values()))
check("личка Alex — круг 1", items[f"conv:{h}"][2] == 1)
check("группа с Vlad — круг 2", items["conv:m-2"][2] == 2)
check("задача правления — круг 2, дайджест — 1",
items["task:t-1"][2] == 2 and items["digest:hour:p1"][2] == 1)
check("заглушка дайджеста не в памяти", "digest:hour:p2" not in items)
check("повторный синк — без новых записей", memory.sync() == 0)
# ── поиск и приватность ──
s2 = memory.search("бюджет альфа казна", circle=2, k=10)
check("тик правления не видит личку владельца", all(x["circle"] >= 2 for x in s2)
and not any("секретный" in x["text"] for x in s2), s2)
s1 = memory.search("бюджет альфа казна", circle=1, k=3)
check("владелец находит по смыслу", s1 and "альфа" in s1[0]["text"], s1)
since = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
s3 = memory.search("бюджет альфа", circle=1, k=10, exclude_chat=A, exclude_since=since)
check("свежий разговор чата не дублируется", not any(x["kind"] == "conv" and "альфа" in x["text"] for x in s3))
# ── глубина ──
cd = lambda lvl, inbox, batch=(), c=1: memory.choose_depth(lvl, list(inbox), list(batch), c)[0]
m_ = lambda t, **k: {"text": t, "chat_id": A, **k}
check("мин по умолчанию — D1", cd("min", [m_("как дела с лендингом")]) == 1)
check("«ок/спасибо» — D0", cd("min", [m_("ок")]) == 0 and cd("min", [m_("Спасибо!")]) == 0)
check("«помнишь…» — D2", cd("min", [m_("помнишь, что мы решили по бюджету?")]) == 2)
check("«что ты делал за месяц» — D3", cd("min", [m_("расскажи что ты делал за последний месяц")]) == 3)
check("задачи в батче — D2", cd("min", [], [{"title": "x"}]) == 2)
check("час — D2, день — D3", cd("hour", []) == 2 and cd("day", []) == 3)
check("потолок круга 4 — D1", cd("min", [m_("что было за месяц")], c=4) == 1)
check("потолок круга 3 — D2", cd("day", [], c=3) == 2)
old = (datetime.now(timezone.utc) - timedelta(days=5)).isoformat()
check("reply на давнее сообщение бота — D2", cd("min", [m_("давай", reply_to_full="x", reply_to_ts=old)]) == 2)
# ── контекст ──
q = msg(A, A, "Alex", "помнишь бюджет проекта альфа? сколько там")
ctx = scheduler.build_context(LEVELS["min"], es.run_projections(), batch=[])
check("контекст D2: блок памяти", "ПАМЯТЬ — НАЙДЕНО ПО СМЫСЛУ (глубина D2" in ctx)
es.append_event("inbox", q, "ReplySent", {"to_chat": A, "text": "40 тысяч"})
msg(G, V, "Vlad", "помнишь бюджет альфа?")
ctx2 = scheduler.build_context(LEVELS["min"], es.run_projections(), batch=[])
check("тик правления: память без лички владельца", "ПАМЯТЬ" not in ctx2 or "секретный" not in ctx2)
es.append_event("inbox", f"m-{N[0]}", "ReplySent", {"to_chat": G, "text": "не скажу"})
msg(A, A, "Alex", "ок")
ctx3 = scheduler.build_context(LEVELS["min"], es.run_projections(), batch=[])
check("D0: без памяти разговора", "НЕДАВНИЙ РАЗГОВОР" not in ctx3 and "ПАМЯТЬ" not in ctx3)
ctxh = scheduler.build_context(LEVELS["hour"], es.run_projections(), batch=[])
check("выжимки: заглушки дайджестов отсеяны", "LLM не запускался" not in ctxh)
# ── DepthChosen ──
memory.choose_depth("min", [m_("помнишь?")], [], 1)
es.append_event("trace", "min-77", "ToolCalled", {"trace_id": "min-77", "tool": "rag_lookup", "input": "{}"})
scheduler._depth_record(LEVELS["min"], "min-77", "ответ")
dc = [json.loads(e["data"]) for e in es.get_events(0, 10**6) if e["event_type"] == "DepthChosen"]
check("DepthChosen: глубина, причины, нехватка памяти", dc and dc[-1]["depth"] == 2
and dc[-1]["under"] is True and dc[-1]["rag_calls"] == 1, dc[-1:])
# ── голова ядра depth: учится на решениях правил + поправках «не хватило» ──
for i in range(60):
past = i % 3 == 0
under = i % 3 == 1 # «расскажи итоги проекта» без слов-триггеров, но агент полез искать
q = ("помнишь, что решили по проекту " if past else
"расскажи статус проекта подробно " if under else "привет как дела ") + str(i)
es.append_event("trace", f"depth:min-{i}", "DepthChosen", {
"level": "min", "trace_id": f"min-{i}", "depth": 2 if past else 1, "reasons": [],
"circle": 1, "query": q, "under": under, "rag_calls": int(under)})
rows = kodi_core.depth_dataset()
check("depth: поправка «не хватило» → метка 1, вес 3",
any(r[1] == 1.0 and r[2] == 3.0 and r[3] == "under" for r in rows))
rep = kodi_core.train(log=lambda *_: None, head="depth")
check("depth: ученик обучен и лучше базы", rep["promoted"] and rep["beats_baseline"], rep.get("holdout"))
_, why = memory.choose_depth("min", [m_("расскажи статус проекта подробно")], [], 1)
check("depth shadow: прогноз ученика в причинах, глубину не меняет",
any("ученик depth" in r for r in why) and memory.last_choice("min")["depth"] == 1, why)
os.environ["SIMBIOT_CORE_DEPTH"] = "on"
kodi_core._forget("depth")
d_on, why_on = memory.choose_depth("min", [m_("расскажи статус проекта подробно")], [], 1)
os.environ.pop("SIMBIOT_CORE_DEPTH")
check("depth on: уверенный ученик поднимает до D2 (если уверен)",
d_on == 2 or not any("уверен" in r for r in why_on), why_on)
print(f"\n{'OK' if not fails else 'FAILED'}: {len(fails)} fail(s)")
sys.exit(1 if fails else 0)