diff --git a/docs/aum/router_usage.json b/docs/aum/router_usage.json new file mode 100644 index 0000000..f35f920 --- /dev/null +++ b/docs/aum/router_usage.json @@ -0,0 +1,15 @@ +{ + "weeks": [ + { + "week_start": "2026-10-19T01:06:57.981175+00:00", + "start_day": "2026-10-19", + "burn_usd": 7.7078, + "reqs": 243, + "days_seen": 2, + "src": "недоступен", + "active": true, + "updated_at": "2026-10-19T01:08:13.330295+00:00" + } + ], + "method": "BudgetSpent стора × блендед-ставка 9.43e-07 у.е./токен; окно 7д, накопительно в рамках недели (t-261019001211)" +} diff --git a/harness/aum.py b/harness/aum.py index 2d2fcf8..cf10ad2 100644 --- a/harness/aum.py +++ b/harness/aum.py @@ -238,6 +238,128 @@ def read_coop_structure(): return {"title": None, "projects": []} +# ───────────── живая петля burn-7д: router_usage.json (t-261019001211) ───────────── +# Слепота AUM-метрики: burn живёт только в тексте policy и теряет историю +# при недельном скольжении. Файл docs/aum/router_usage.json — компактный реестр +# «неделя → замер» с ежегодным сбросом окна: каждая запись — сумма расхода +# с момента прошлого замера (окно кратно ~7д, ровно одна активная неделя). +# Идёмпотентность: замер того же дня не создаёт новую запись, а дополняет +# активное окно (докупка ΔB = sum_7d − уже_записанное). Так скользящее окно +# BudgetSpent-стора превращается в стабильный накопительный реестр: виден +# и фактический burn недели, и Δ-тренд неделя-к-неделе. + +ROUTER_USAGE_JSON = os.environ.get( + "SIMBIOT_ROUTER_USAGE_JSON", + os.path.join(os.path.dirname(BALANCES_JSON), "router_usage.json")) + + +def update_router_usage(snap: dict = None, path: str = None): + """Дополнить реестр router_usage.json свежим замером burn (7д). + + Вход — снапшот aum.measure() (или собирается сам). Логика: + - если активной записи нет или её окно старше 21д — новая запись; + - если окно той же недели — досчитать Δburn от уже записанного + (скользящее окно BudgetSpent суммирует 7д заново, реестр — накопительно); + - окно ровно одно активное; история капится 260 записями (5 лет). + Ошибки не бросает (best effort, как и весь AUM-контур) — возвращает dict. + """ + path = path or os.environ.get("SIMBIOT_ROUTER_USAGE_JSON", ROUTER_USAGE_JSON) + snap = snap or measure() + days = snap.get("spend_days") or {} + sum7 = round(sum(v.get("cost", 0) for v in days.values()), 4) + today = snap.get("ts", "")[:10] + try: + from datetime import date + try: + today_d = date.fromisoformat(today) + except ValueError: + today_d = date.today() + try: + with open(path, encoding="utf-8") as f: + reg = json.load(f) + except Exception: + reg = {"weeks": []} + weeks = reg.setdefault("weeks", []) + cur = weeks[-1] if weeks else None + stale = True + if cur and cur.get("active") and cur.get("start_day"): + try: + stale = (today_d - date.fromisoformat(cur["start_day"])).days > 21 + except ValueError: + stale = True + fresh = False + if (not cur or not cur.get("active") or stale): + # закрываем прошлое окно и заводим новое (сброс недели) + if cur and cur.get("active"): + cur["active"] = False + cur["closed_at"] = snap.get("ts") + cur = {"week_start": snap.get("ts"), "start_day": today, + "burn_usd": 0.0, "reqs": 0, "days_seen": 0, + "src": snap.get("burn_src"), "active": True} + weeks.append(cur) + fresh = True + # накопительная досумма поверх уже записанного в этом окне + prev = float(cur.get("burn_usd") or 0) + delta = round(sum7 - prev, 4) + if delta < 0: # скользящее окно выпало из старых дней — не отнимаем + delta = 0.0 + cur["burn_usd"] = round(prev + delta, 4) + cur["reqs"] = max(int(cur.get("reqs") or 0), + sum(v.get("reqs", 0) for v in days.values())) + cur["days_seen"] = max(int(cur.get("days_seen") or 0), len(days)) + cur["src"] = snap.get("burn_src") or cur.get("src") + cur["updated_at"] = snap.get("ts") + if len(weeks) > 260: + reg["weeks"] = weeks[-260:] + reg["method"] = ("BudgetSpent стора × блендед-ставка " + f"{BURN_RATE_PER_TOKEN:.2e} у.е./токен; окно 7д, " + "накопительно в рамках недели (t-261019001211)") + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(reg, f, ensure_ascii=False, indent=2) + f.write("\n") + os.replace(tmp, path) # атомарно: читатели не увидят полуфайл + return {"path": path, "week_start": cur.get("week_start"), + "burn_usd": cur["burn_usd"], "delta_usd": delta, + "fresh_week": fresh} + except Exception as e: + return {"path": path, "error": str(e)[:200]} + + +# ────────── env-доктор авто-источников AUM (t-261019001211, пункт 2) ────────── +# balances.json сейчас слеп: OPENROUTER_KEY и STRIPE_LIVE_KEY живут на другом +# хосте (KE-SREGISTRY). Чтобы запрос к Alex не был «сделай что-то», доктор +# собирает точный список: каких переменных в каком .env не хватает и что +# именно каждая открывает в метрике. + +ENV_KEYS_NEEDED = ( + # (переменная, файл, что открывает в AUM-петле) + ("OPENROUTER_KEY", "simbiot-harness/.env", + "кредиты OpenRouter (auto-баланс №1, сейчас 26.7 фикс с 2026-10-04)"), + ("STRIPE_LIVE_KEY", "/root/coop/.env", + "Stripe live available (auto-баланс, подписки $99/мес)"), + ("SIMBIOT_ROUTER_DB", "simbiot-harness/.env", + "живая БД 9router (usageHistory): точный burn вместо блендед-оценки"), +) + + +def env_doctor(env_path: str = None, coop_env: str = "/root/coop/.env"): + """Какие ключи отсутствуют + готовый шаблон запроса к Alex (без значений).""" + env_path = env_path or _paths.env_path() + missing = [] + for var, where, why in ENV_KEYS_NEEDED: + path = coop_env if var == "STRIPE_LIVE_KEY" else env_path + present = _env_file_value(path, var) is not None + if not present: + missing.append({"var": var, "env_file": path, "opens": why}) + request = ("Чтобы петля AUM видела деньги без ручного ввода, добавь в .env:\n" + + "\n".join(f" {m['var']}=… # в {m['env_file']} — {m['opens']}" + for m in missing)) if missing else \ + "Все ключи авто-источников на месте — петля AUM не слепа." + return {"missing": missing, "request_to_alex": request, + "checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds")} + + # ────────────────────────── замер ────────────────────────── def measure(): @@ -258,6 +380,15 @@ def measure(): if not spend: # БД роутера недоступна → не молчим, а считаем из стора spend = read_store_spend(days=7) spend_src = "store-BudgetSpent-оценка" if spend else "недоступен" + # живая петля burn-7д (t-261019001211): каждый замер дополняет реестр + # router_usage.json (накопительное окно недели) — контур не теряет историю + if os.environ.get("SIMBIOT_AUM_USAGE_REGISTRY", "1") not in ("0", "false", "no"): + _snap = {"ts": datetime.now(timezone.utc).isoformat(), + "spend_days": spend, "burn_src": spend_src} + try: + update_router_usage(_snap) + except Exception: + pass # реестр — best effort: замер важнее и идёт всегда days_iter = list(spend.values()) return { "ts": datetime.now(timezone.utc).isoformat(), @@ -368,6 +499,25 @@ def run_pilot(m: dict) -> dict: "note": "вход — суточный burn 9router; выход — рекомендации у.е. расхода"} +def weekly_burn_delta(path: str = None): + """Δ burn последняя-прошлая неделя из router_usage.json (у.е. или None). + + Пункт 4 петли (t-261019001211): манифесту дня нужен не только абсолют + burn, но и тренд — растём или падаем относительно прошлой недели. + """ + path = path or os.environ.get("SIMBIOT_ROUTER_USAGE_JSON", ROUTER_USAGE_JSON) + try: + with open(path, encoding="utf-8") as f: + weeks = json.load(f).get("weeks", []) + if len(weeks) < 2: + return None + cur, prev = weeks[-1], weeks[-2] + return round(float(cur.get("burn_usd") or 0) + - float(prev.get("burn_usd") or 0), 2) + except Exception: + return None + + def build_policy_aum_summary(m: dict, pilot: dict = None) -> str: """Однострочник для policy/контекста всех уровней.""" aum = m.get("aum") @@ -376,13 +526,16 @@ def build_policy_aum_summary(m: dict, pilot: dict = None) -> str: "docs/aum/balances.json)") ev = (pilot or {}).get("evolution") or {} tail = f"; последний отбор: {ev.get('verdict', 'не запускался')}" if pilot else "" + wd = weekly_burn_delta() + trend = (f"; Δburn к прошлой неделе: {'+' if wd >= 0 else ''}{wd} у.е." + if wd is not None else "") src = m.get("burn_src") or "9router-db" burn = (f"≈{m.get('burn_7d', 0)} (оценка: BudgetSpent стора × блендед-ставка; " f"живая БД 9router недоступна)" if src.startswith("store") else f"{m.get('burn_7d', 0)}") return (f"ЦЕЛЬ: максимизация долгосрочной AUM (sum всех измеримых ресурсов " f"на длинной дистанции). {part}; burn 9router 7д=" - f"{burn} у.е. (прямой минус к AUM). Фильтр любого " + f"{burn} у.е.{trend} (прямой минус к AUM). Фильтр любого " f"решения и задачи: «добавляет ≥ +1% к AUM в горизонте год+?»; " f"короткий доход, съедающий долгосрочную базу — ниже приоритетом." f"{tail}") \ No newline at end of file diff --git a/harness/scheduler.py b/harness/scheduler.py index cc70b54..f07c51b 100644 --- a/harness/scheduler.py +++ b/harness/scheduler.py @@ -1256,6 +1256,26 @@ def process_level(level_cfg, model: dict, seq: int = None): except Exception: pass # замер — best effort, не валить тик + # живая петля AUM-метрики (t-261019001211): дневной снапшот — раз в сутки + # (см. выше), часовой дописывает внутрисуточные точки в aum_history — + # спарклайн дашборда оживает, а не обновляется раз в день. Идемпотентно + # по aggregate_id=aum:hour:: повторный тик часа не задублирует. + if level_cfg.name == "hour": + try: + from . import aum as _aum + snap = _aum.measure() + hour_key = snap["ts"][:13] # 2026-10-19T01 + ev = es.append_event("harness", f"hour:{hour_key}", "AumMeasured", { + "ts": snap["ts"], "aum": snap["aum"], + "burn_24h": snap["burn_24h"], "burn_7d": snap["burn_7d"], + "policy": {"aum_target": _aum.build_policy_aum_summary(snap)}, + }) + if ev: + _log(f"aum: часовая точка {hour_key} записана " + f"(burn_24h={snap['burn_24h']})") + except Exception: + pass # замер — best effort, не валить тик + # ГЕЙТ ВАЖНОСТИ (e-260930062242/2510/2607/2741): LLM-тик запускается # только если есть событие ≥ порога уровня; тихий такт отрабатывает # детерминированной механикой (как sec) — без расхода кредитов роутера. diff --git a/tests/test_aum.py b/tests/test_aum.py index c0023d7..737e62a 100644 --- a/tests/test_aum.py +++ b/tests/test_aum.py @@ -206,6 +206,98 @@ 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) \ No newline at end of file