# -*- 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) # «мешок слов» даёт оценки ниже nomic — пороги под него (калибровка nomic — в коде) os.environ["SIMBIOT_MEMORY_MIN_SCORE"] = "0.2" os.environ["SIMBIOT_MEMORY_REL_WINDOW"] = "1.0" 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)