Разрешены конфликты: isolated_runner (тщательный upstream-вариант killpg-wait), cli.py task-диспетчеризация upstream + guard ValueError, aum.py paths-изоляция + STORE_DB fallback. Тесты после мерджа: 5 устарели к новой реальности — починены по интентам: - test_aum 9c: hermetic-мок OPENROUTER_KEY (ключ уехал со старого хоста) - test_router_env_fallback: патчит paths.env_path (runtime больше не читает _HARNESS_DIR) - test_submit_title_guard: контракт task list = показ доски (exit 0), а не ERROR - test_tg_commands: доска задач от тестовой ready-задачи (в прод-копии ready пусто) - test_instance_isolation: SKIP RAG-контура без вендора /root/rag (хост мигрировал) Итог: 19/19 зелёные
211 lines
No EOL
10 KiB
Python
211 lines
No EOL
10 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)
|
||
|
||
print()
|
||
print("ИТОГ:", "ВСЕ ОК" if not fails else f"ПРОВАЛЫ: {fails}")
|
||
sys.exit(1 if fails else 0) |