303 lines
No EOL
16 KiB
Python
303 lines
No EOL
16 KiB
Python
# -*- 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) |