simbiot-harness/tests/test_aum.py

303 lines
No EOL
16 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 -*-
"""t-260929161138: north-star метрика AUM (hermetic, без LLM/TG/9router).
Запуск: ./venv/bin/python tests/test_aum.py
Проверяет: замер не падает без БД роутера и balances.json (AUM=None),
детерминизм пилота (seed), отбор находит конфигурацию не хуже бейзлайна,
событие AumMeasured мержит policy.aum_target + копит aum_history (кап 120),
build_context инжектит «ЦЕЛЬ (AUM)» всем уровням, CLI aum/aum-pilot/--record
существуют, AumMeasured идемпотентен по aggregate_id.
"""
import io
import json
import os
import sys
import contextlib
# ── изоляция: своя БД в /tmp, роутер и balances заведомо отсутствуют ──
_DST = "/tmp/simbiot-test-aum.db"
for p in (_DST, _DST + "-wal", _DST + "-shm"):
if os.path.exists(p):
os.unlink(p)
os.environ["SIMBIOT_DB"] = _DST
os.environ["SIMBIOT_ROUTER_DB"] = "/nonexistent-router.sqlite"
BAL_TMP = "/tmp/simbiot-test-balances.json"
if os.path.exists(BAL_TMP):
os.unlink(BAL_TMP)
os.environ["SIMBIOT_BALANCES_JSON"] = BAL_TMP
sys.path.insert(0, "/root/simbiot-harness")
from harness import aum, es # noqa: E402
from harness.levels import LEVELS # noqa: E402
from harness.scheduler import build_context # noqa: E402
fails = []
def check(name, cond, extra=""):
print(("PASS" if cond else "FAIL"), name, ("| " + extra) if not cond else "")
if not cond:
fails.append(name)
# ── 1. замер hermetic: не падает, AUM=None, burn=0 ──
m = aum.measure()
check("measure hermetic ok", set(m) >= {"ts", "aum", "burn_24h", "burn_7d", "spend_days"})
check("aum None без файла", m["aum"] is None)
check("burn 0 без роутера", m["burn_7d"] == 0 and m["spend_days"] == {})
# ── 2. balances с данными → total ──
with open(BAL_TMP, "w") as f:
json.dump({"accounts": [{"name": "a", "amount_usd": 12.5},
{"name": "b", "amount_usd": 7.5}]}, f)
m2 = aum.measure()
check("balances→total", m2["aum"] == 20.0)
# ── 3. детерминизм эволюции ──
h = [60., 300., 900., 500., 150., 200.]
e1, e2 = aum.evolve(h), aum.evolve(h)
check("эволюция детерминирована",
e1["best"] == e2["best"] and e1["best_fitness"] == e2["best_fitness"])
check("отбор не хуже бейзлайна", e1["best_fitness"] >= e1["baseline_fitness"] - 1e-6)
check("fitness_history по поколениям", len(e1["fitness_history"]) == e1["generations"])
# ── 4. симуляция: больше burn без отдачи — всегда минус (плейсхолдер честен) ──
s_small = aum.simulate({"volume_mult": 0.2, "flash_share": 0.84}, h, aum.calibrate(h, {"volume_mult": 1.0, "flash_share": 0.84}))
s_big = aum.simulate({"volume_mult": 2.5, "flash_share": 0.84}, h, aum.calibrate(h, {"volume_mult": 1.0, "flash_share": 0.84}))
check("меньше объёма burn → лучше при плейсхолдер-модели",
s_small["mean"] > s_big["mean"])
# ── 5. события AumMeasured: policy мержится, история копится, кап 120 ──
# (повторный aggregate_id легален — каждая версия = новый снапшот; UNIQUE в es
# защищает только тики cycle. Дневная частота обеспечивается идемпотентностью тика.)
es.init_db()
es.append_event("harness", "aum", "AumMeasured", {
"ts": "2026-10-02T00:00:00+00:00", "aum": 20.0, "burn_24h": 1.5,
"burn_7d": 10.0,
"policy": {"aum_target": "ЦЕЛЬ: AUM↑ (тест)"}}
)
model = es.run_projections()
check("policy.aum_target записан", "AUM" in (model["policy"].get("aum_target") or ""))
check("aum_history ведётся", len(model.get("aum_history", [])) == 1)
es.append_event("harness", "aum", "AumMeasured", {
"ts": "2026-10-02T06:00:00+00:00", "aum": 21.0, "burn_24h": 1.0,
"burn_7d": 11.0, "policy": {"aum_target": "ЦЕЛЬ: AUM↑ (тест2)"}})
model = es.run_projections()
check("повторный снапшот апендится в историю",
len(model.get("aum_history", [])) == 2)
check("aum_target перезаписан свежим",
"тест2" in (model["policy"].get("aum_target") or ""))
for i in range(118):
es.append_event("harness", "aum", "AumMeasured", {
"ts": f"2026-10-03T{i:02d}:00:00+00:00", "aum": float(i),
"burn_24h": float(i), "burn_7d": float(i),
"policy": {"aum_target": f"x{i}"}})
model = es.run_projections()
check("кап истории 120", len(model.get("aum_history", [])) == 120)
# ── 6. build_context инжектит цель всем LLM-уровням ──
ok_ctx = True
for name in ("min", "hour", "day"):
ctx = build_context(LEVELS[name], model, batch=[])
if "ЦЕЛЬ (AUM)" not in ctx:
ok_ctx = False
print(f" ({name}: цель не в контексте)")
check("ЦЕЛЬ (AUM) в контексте min/hour/day", ok_ctx)
# ── 7. summary содержит фильтр +1% и burn ──
summ = aum.build_policy_aum_summary(m2, {"evolution": e1})
check("summary: фильтр +1% и burn", "+1%" in summ and "burn" in summ)
# ── 8. CLI: команды существуют и работают hermetic ──
env = dict(os.environ, SIMBIOT_DB=_DST, SIMBIOT_ROUTER_DB="/nonexistent-router.sqlite",
SIMBIOT_BALANCES_JSON=BAL_TMP)
import subprocess
r = subprocess.run(["/root/simbiot-harness/venv/bin/python", "/root/simbiot-harness/cli.py", "aum"],
capture_output=True, text=True, timeout=60, env=env)
check("cli aum ok", r.returncode == 0 and "AUM" in r.stdout, r.stderr[:200])
r = subprocess.run(["/root/simbiot-harness/venv/bin/python", "/root/simbiot-harness/cli.py",
"aum", "--record"], capture_output=True, text=True, timeout=60, env=env)
check("cli aum --record ok -> стор читает цель",
r.returncode == 0 and "OK: AumMeasured" in r.stdout)
model = es.run_projections()
check("aum_target в сторе после --record",
"AUM" in (model["policy"].get("aum_target") or ""))
r = subprocess.run(["/root/simbiot-harness/venv/bin/python", "/root/simbiot-harness/cli.py", "aum-pilot"],
capture_output=True, text=True, timeout=120, env=env)
check("cli aum-pilot ok", r.returncode == 0 and "вердикт" in r.stdout, r.stderr[:200])
# ── 9. авто-обновление балансов из API (t-261004000834-ed84f5) — hermetic ──
import tempfile
tmpdir = tempfile.mkdtemp(prefix="simbiot-aum-")
# 9a: файла нет → [] без падения
check("refresh_auto: нет файла → []",
aum.refresh_auto_balances(path="/nonexistent-balances.json") == [])
# 9b: auto-счёт, но ключей нет → значение не пишется, ошибок нет
bal_nokey = os.path.join(tmpdir, "b1.json")
with open(bal_nokey, "w") as f:
json.dump({"accounts": [{"name": "Stripe live balance (available)",
"amount_usd": 0.0, "auto": True}]}, f)
_saved_efv = aum._env_file_value
aum._env_file_value = lambda *a, **k: None # ключей нет
try:
got = aum.refresh_auto_balances(path=bal_nokey, coop_env=os.path.join(tmpdir, "no.env"))
finally:
aum._env_file_value = _saved_efv
check("refresh_auto: без ключа → [] и файл не тронут",
got == [] and json.load(open(bal_nokey))["accounts"][0]["amount_usd"] == 0.0)
# 9c: _http_json мокается → суммы пишутся, measured_at/as_of обновляются,
# ручные счета не трогаются
bal_mock = os.path.join(tmpdir, "b2.json")
with open(bal_mock, "w") as f:
json.dump({"accounts": [
{"name": "OpenRouter кредиты", "amount_usd": None, "auto": True},
{"name": "Stripe live balance", "amount_usd": None, "auto": True},
{"name": "ручной счёт Alex", "amount_usd": 5.0}],
"as_of": "2000-01-01"}, f)
with open(os.path.join(tmpdir, "stripe.env"), "w") as f:
f.write("STRIPE_LIVE_KEY=sk_test_fake\n")
def _fake_http(url, headers):
if "openrouter" in url:
return {"data": {"total_credits": 100, "total_usage": 40}}
return {"livemode": True, "available": [{"currency": "usd", "amount": 12345}]}
_saved_http = aum._http_json
aum._http_json = _fake_http
# hermetic: OPENROUTER_KEY должен приходить из мока .env, а не из реального
# .env хоста (на мигрированном инстансе ключа нет — тест падал негерметично)
_saved_efv2 = aum._env_file_value
# hermetic: OPENROUTER_KEY из реального .env хоста не существует (миграция
# инстанса) — даём мок; STRIPE_LIVE_KEY читается из coop_env-файла теста
# через настоящий _env_file_value, его не подменяем
aum._env_file_value = (lambda path, prefix:
"sk-or-test" if prefix == "OPENROUTER_KEY"
else _saved_efv2(path, prefix))
try:
got2 = aum.refresh_auto_balances(path=bal_mock,
coop_env=os.path.join(tmpdir, "stripe.env"))
finally:
aum._http_json = _saved_http
aum._env_file_value = _saved_efv2
d2 = json.load(open(bal_mock))
check("refresh_auto: mock API → 60.0 и 123.45 записаны",
len(got2) == 2 and abs(d2["accounts"][0]["amount_usd"] - 60.0) < 0.001
and abs(d2["accounts"][1]["amount_usd"] - 123.45) < 0.001,
f"got2={got2} d2={d2}")
check("refresh_auto: measured_at у auto, ручной не тронут, as_of обновлён",
bool(d2["accounts"][0].get("measured_at"))
and d2["accounts"][2]["amount_usd"] == 5.0 and d2["as_of"] != "2000-01-01")
# 9d: Stripe livemode=False → None: тестовые деньги в AUM не актив
def _fake_http_testmode(url, headers):
return {"livemode": False, "available": [{"currency": "usd", "amount": 33524}]}
aum._http_json = _fake_http_testmode
try:
v_test = aum.refresh_stripe_available(coop_env=os.path.join(tmpdir, "stripe.env"))
finally:
aum._http_json = _saved_http
check("stripe: livemode=False → None (не актив)", v_test is None)
# ── 10. живая петля burn-7д: router_usage.json (t-261019001211) ──
RU = os.path.join(tmpdir, "router_usage.json")
for p in (RU,):
if os.path.exists(p):
os.unlink(p)
# 10a: первый замер → новая активная неделя
r1 = aum.update_router_usage({"ts": "2026-10-12T00:00:00+00:00",
"burn_src": "store-BudgetSpent-оценка",
"spend_days": {"2026-10-11": {"cost": 2.0, "reqs": 10},
"2026-10-12": {"cost": 1.0, "reqs": 5}}}, path=RU)
w = json.load(open(RU))["weeks"]
check("router_usage: первая неделя создана",
r1.get("fresh_week") and len(w) == 1 and w[0]["active"]
and abs(w[0]["burn_usd"] - 3.0) < 0.001, str(r1))
# 10b: тот же день, окно подросло → накопительная досумма (не дубль полной суммы)
r2 = aum.update_router_usage({"ts": "2026-10-12T12:00:00+00:00",
"burn_src": "store-BudgetSpent-оценка",
"spend_days": {"2026-10-11": {"cost": 2.0, "reqs": 10},
"2026-10-12": {"cost": 2.5, "reqs": 12}}}, path=RU)
w = json.load(open(RU))["weeks"]
check("router_usage: досумма Δ в той же неделе",
not r2.get("fresh_week") and abs(r2.get("delta_usd", -1) - 1.5) < 0.001
and abs(w[0]["burn_usd"] - 4.5) < 0.001, f"r2={r2}")
# 10c: скользящее окно сжалось (выпал старый день) — не отнимаем
r3 = aum.update_router_usage({"ts": "2026-10-13T00:00:00+00:00",
"burn_src": "store-BudgetSpent-оценка",
"spend_days": {"2026-10-12": {"cost": 2.5, "reqs": 12},
"2026-10-13": {"cost": 1.0, "reqs": 4}}}, path=RU)
w = json.load(open(RU))["weeks"]
check("router_usage: сжатие окна не отнимает burn",
abs(r3.get("delta_usd", -1)) < 0.001 and abs(w[0]["burn_usd"] - 4.5) < 0.001,
f"r3={r3}")
# 10d: окно старше 21д → сброс, новое окно, старое закрыто
r4 = aum.update_router_usage({"ts": "2026-11-15T00:00:00+00:00",
"burn_src": "store-BudgetSpent-оценка",
"spend_days": {"2026-11-14": {"cost": 7.0, "reqs": 30}}}, path=RU)
w = json.load(open(RU))["weeks"]
check("router_usage: недельный сброс окна",
r4.get("fresh_week") and len(w) == 2 and not w[0]["active"]
and w[1]["active"] and abs(w[1]["burn_usd"] - 7.0) < 0.001, str(r4))
# 10e: битый путь → ошибка в dict, без исключения
r5 = aum.update_router_usage({"ts": "2026-11-15T01:00:00+00:00",
"spend_days": {"2026-11-14": {"cost": 1.0, "reqs": 3}}},
path="/nonexistent-dir/ru.json")
check("router_usage: ошибка тихо в dict", "error" in r5)
# 10f: measure() ведёт реестр сам (интеграция: hermetic, реестр в tmp)
os.environ["SIMBIOT_ROUTER_USAGE_JSON"] = os.path.join(tmpdir, "ru2.json")
_m = aum.measure()
check("measure: router_usage пишется из замера",
os.path.exists(os.environ["SIMBIOT_ROUTER_USAGE_JSON"]))
# ── 11. env-доктор авто-источников (t-261019001211) ──
_empty = os.path.join(tmpdir, "empty.env")
with open(_empty, "w") as f:
f.write("# пусто\n")
doc = aum.env_doctor(env_path=_empty, coop_env=os.path.join(tmpdir, "no-coop.env"))
check("env_doctor: все три ключа отсутствуют на пустых .env",
[m["var"] for m in doc["missing"]] ==
["OPENROUTER_KEY", "STRIPE_LIVE_KEY", "SIMBIOT_ROUTER_DB"], str(doc["missing"]))
check("env_doctor: шаблон запроса к Alex готов",
"OPENROUTER_KEY" in doc["request_to_alex"]
and "STRIPE_LIVE_KEY" in doc["request_to_alex"])
_full = os.path.join(tmpdir, "full.env")
with open(_full, "w") as f:
f.write("OPENROUTER_KEY=sk-test\nSIMBIOT_ROUTER_DB=/tmp/x.sqlite\n")
_coop_full = os.path.join(tmpdir, "coop.env")
with open(_coop_full, "w") as f:
f.write("STRIPE_LIVE_KEY=sk-live-test\n")
doc2 = aum.env_doctor(env_path=_full, coop_env=_coop_full)
check("env_doctor: полный .env → missing=[]",
doc2["missing"] == [] and "не слепа" in doc2["request_to_alex"])
# ── 12. Δ burn неделя-к-неделе (t-261019001211, пункт 4) ──
RU2 = os.path.join(tmpdir, "ru_delta.json")
with open(RU2, "w") as f:
json.dump({"weeks": [{"burn_usd": 10.0, "active": False},
{"burn_usd": 7.5, "active": True}]}, f)
check("weekly_burn_delta: −2.5", aum.weekly_burn_delta(path=RU2) == -2.5)
os.environ["SIMBIOT_ROUTER_USAGE_JSON"] = RU2
check("weekly_burn_delta: одна неделя → None",
aum.weekly_burn_delta(path=os.path.join(tmpdir, "ru-one.json")) is None)
s_wd = aum.build_policy_aum_summary({"aum": 26.7, "burn_7d": 7.5,
"burn_src": "store-BudgetSpent-оценка"})
check("summary: Δburn неделя-к-неделе в контексте", "Δburn" in s_wd and "-2.5" in s_wd,
s_wd)
print()
print("ИТОГ:", "ВСЕ ОК" if not fails else f"ПРОВАЛЫ: {fails}")
sys.exit(1 if fails else 0)