"""Simbiot Harness — North Star metric: долгосрочная AUM (t-260929161138). Формализует главную цель (фиксация Alex, m-516173142): максимизация суммарных измеримых ресурсов кооператива на длинной дистанции. Состав метрики: - AUM_t — балансы кооператива в у.е. (USD-эквивалент). Источник уровня день: docs/aum/balances.json (ручной ввод Alex, схема {"accounts":[{"name":..., "amount_usd":...}]}); пока файла нет — AUM = None (не замерен), но драйверы считаются уже сейчас. - burn_t — расход на LLM-инфраструктуру (9router, живая БД usageHistory /root/.9router/db/data.sqlite, env SIMBIOT_ROUTER_DB переопределяет; на тестах БД роутера не требуется — ошибка чтения → пустой замер). - ΔAUM(t) = revenue_t − burn_t; фильтр решений: «даёт ≥ +1% к AUM в горизонте год+?» (README, раздел «Главная цель»). Петля оптимизации (пилот v0.1, песочница): симуляция сценариев (бутстрап исторических суточных burn 9router) + генетический отбор конфигураций расхода по fitness = E[ΔAUM горизонта]. Результат — АДВОКАТ-отчёт (рекомендация дню/Alex'у), НЕ авто-применение. Модель продуктивности — честный плейсхолдер (см. docs/aum.md): бюджет дня покупает токены по ставкам моделей (glm-5.3-flash / glm-5.3 из дайджеста расходов 25.09–02.10), выручка = P·√(полезность токенов), полезность flash-токенов слегка ниже (q_flash=0.95). P калибруется так, чтобы бейзлайн «как сейчас» давал −5% в день (лёгкое давление к оптимизации). В v0.2 P заменяется реальными выручочными ставками проектов кооператива. """ import json import os import random import sqlite3 import statistics from datetime import datetime, timezone # ────────────────────────── источники данных ────────────────────────── ROUTER_DB = os.environ.get( "SIMBIOT_ROUTER_DB", "/root/.9router/db/data.sqlite") from . import paths as _paths # t-261004113351-2858c1: доки инстанса # fallback-источник burn: стор этого инстанса (instance-aware, e-26100607*) STORE_DB = _paths.db_path() HARNESS_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BALANCES_JSON = os.path.join(_paths.docs_dir(), "aum", "balances.json") COOP_JSON = os.environ.get("SIMBIOT_COOP_JSON", "/root/coop_data.json") RATE_FLASH = 0.000001022 # у.е./токен glm-5.3-flash (дайджест 25.09–02.10; ×1e-3 фикс f3a1d6) RATE_FULL = 0.000001058 # у.е./токен glm-5.3 (тот же фикс порядка величины) Q_FLASH = 0.95 # полезность flash-токена относительно полного BASELINE_NET = 0.95 # выручка бейзлайна = 95% расхода (−5%/день) HORIZON_DAYS = 365 def _fmt(v): return None if v is None else round(float(v), 2) # ────────────────────────── чтение данных ────────────────────────── def read_balances(): """Балансы кооператива из docs/aum/balances.json. total=None если нет файла.""" try: path = os.environ.get("SIMBIOT_BALANCES_JSON", BALANCES_JSON) with open(path) as f: data = json.load(f) accs = [a for a in data.get("accounts", []) if a.get("amount_usd") is not None] return {"total": _fmt(sum(float(a["amount_usd"]) for a in accs)) if accs else None, "accounts": accs, "src": "balances.json"} except Exception: return {"total": None, "accounts": [], "src": "balances.json (нет файла)"} # ────────────────── авто-источники балансов (t-261004000834-ed84f5) ────────────────── # AUM не должен жить только на ручном вводе: что измеряется API — измеряем сами. # Принцип: read-only GET, ключи только из локальных .env (в сток/логи не попадают), # ошибка сети/ключа → старое значение остаётся (stale-факт честнее выдуманного). AUTO_REFRESH_TIMEOUT = 15 # сек на API-запрос def _env_file_value(path: str, prefix: str): """Значение VAR=… из .env-файла (первое вхождение) или None.""" try: with open(path, encoding="utf-8") as f: for line in f: if line.startswith(prefix + "="): return line.split("=", 1)[1].strip().strip('"').strip("'").strip() or None except OSError: pass return None def _http_json(url: str, headers: dict): import urllib.request req = urllib.request.Request(url, headers=headers) with urllib.request.urlopen(req, timeout=AUTO_REFRESH_TIMEOUT) as resp: return json.load(resp) def refresh_openrouter_credits(env_path: str = None): """Доступные кредиты OpenRouter коопа (USD) или None (нет ключа/ошибка).""" env_path = env_path or _paths.env_path() # t-261004113351: .env инстанса key = _env_file_value(env_path, "OPENROUTER_KEY") if not key: return None try: d = _http_json("https://openrouter.ai/api/v1/credits", {"Authorization": f"Bearer {key}"}) inf = d.get("data", {}) total, usage = inf.get("total_credits"), inf.get("total_usage") if total is None: return None return round(float(total) - float(usage or 0.0), 2) except Exception: return None def refresh_stripe_available(coop_env: str = "/root/coop/.env"): """Stripe live available USD или None. Только livemode=True: тестовые деньги (livemode=False) в AUM не актив — иначе метрика врёт.""" key = _env_file_value(coop_env, "STRIPE_LIVE_KEY") if not key: return None try: d = _http_json("https://api.stripe.com/v1/balance", {"Authorization": f"Bearer {key}"}) if not d.get("livemode"): return None for b in d.get("available", []): if b.get("currency") == "usd": return round(b["amount"] / 100.0, 2) return 0.0 except Exception: return None def refresh_auto_balances(path: str = None, coop_env: str = "/root/coop/.env"): """Обновить accounts с auto:true в balances.json значениями из API. Роутинг по имени счёта (openrouter → кредиты, stripe → live available). Файла нет / ни одно auto-поле не обновилось → ничего не пишем. Возвращает список имён обновлённых счетов.""" path = path or os.environ.get("SIMBIOT_BALANCES_JSON", BALANCES_JSON) try: with open(path, encoding="utf-8") as f: data = json.load(f) except Exception: return [] updated = [] for acc in data.get("accounts", []): if not acc.get("auto"): continue name = (acc.get("name") or "").lower() val = None if "openrouter" in name: val = refresh_openrouter_credits() elif "stripe" in name: val = refresh_stripe_available(coop_env) if val is None: continue acc["amount_usd"] = val acc["measured_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds") updated.append(acc.get("name")) if not updated: return [] data["as_of"] = datetime.now(timezone.utc).date().isoformat() try: tmp = path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) f.write("\n") os.replace(tmp, path) # атомарно: читатели не увидят полуфайл except Exception: return [] return updated def read_router_spend(days: int = 7): """Суточный расход 9router за N дней: {date: {cost, reqs}}. Ошибка → {}.""" try: db = sqlite3.connect(f"file:{ROUTER_DB}?mode=ro", timeout=5, uri=True) rows = db.execute( "SELECT substr(timestamp,1,10) d, ROUND(SUM(cost),4) c, COUNT(*) r " "FROM usageHistory WHERE timestamp >= datetime('now', ?) " "GROUP BY d ORDER BY d", (f"-{int(days)} days",)).fetchall() db.close() return {d: {"cost": c, "reqs": r} for d, c, r in rows} except Exception: return {} # Блендед-ставка токен→у.е., откалибрована на окне 25.09–02.10 # (docs/digests/9router-spend-2026-09-25_10-02.md): 2491.47 у.е. / # 2.642 млрд токенов флота = 9.43e-7 у.е./токен. Кросс-чек тем же дайджестом: # simbiot-harness 299.11 у.е. / (15227 req × 19.7K tok) = 9.97e-7 — сходится. # t-261007082307-f3a1d6: найдено и исправлено расхождение 1000× (было 0.000943): # порядок величины вчитан неверно, burn-оценка была завышена втрое-на-порядок. BURN_RATE_PER_TOKEN = float(os.environ.get( "SIMBIOT_BURN_RATE_PER_TOKEN", "0.000000943")) def read_store_spend(days: int = 7): """Fallback-расход из событий BudgetSpent стора харнеса (только СВОИ тики). Используется, когда живая БД 9router недоступна (миграция хоста унесла /root/.9router): метрика burn не должна молча зануляться — иначе policy.aum_target врёт «burn=0» в контексте каждого тика. Оценка блендед-ставкой; в событиях нет разбивки по моделям. """ try: db = sqlite3.connect(f"file:{STORE_DB}?mode=ro", timeout=5, uri=True) rows = db.execute( "SELECT substr(timestamp,1,10) d, COUNT(*) r, " "SUM(COALESCE(json_extract(data,'$.prompt_tokens'),0)" "+COALESCE(json_extract(data,'$.completion_tokens'),0)) t " "FROM events WHERE event_type='BudgetSpent' " "AND timestamp >= datetime('now', ?) " "GROUP BY d ORDER BY d", (f"-{int(days)} days",)).fetchall() db.close() return {d: {"cost": round(t * BURN_RATE_PER_TOKEN, 4), "reqs": r} for d, r, t in rows} except Exception: return {} def read_coop_structure(): """Структура кооператива (проекты/выручочные доли) — множитель метрики.""" try: with open(COOP_JSON) as f: data = json.load(f) return {"title": (data.get("mission") or {}).get("title"), "projects": [{"name": p.get("name"), "revenue_pct": p.get("revenue_pct")} for p in data.get("projects", [])]} except Exception: 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(): """Снапшот метрики: AUM (если замерен) + burn 24ч/7д + структура кооператива. Авто-балансы (accounts c auto:true) перед замером обновляются из API (t-261004000834-ed84f5). SIMBIOT_AUM_AUTO_REFRESH=0 выключает (тесты/ офлайн-режим); сетевая ошибка не валит снапшот — останется прошлое значение (stale честнее выдуманного).""" if os.environ.get("SIMBIOT_AUM_AUTO_REFRESH", "1") not in ("0", "false", "no"): try: refresh_auto_balances() except Exception: pass # обновление — best effort: замер burn важнее и идёт всегда bal = read_balances() spend = read_router_spend(days=7) spend_src = "9router-db" 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(), "aum": bal["total"], "balances_src": bal["src"], "burn_24h": _fmt(days_iter[-1]["cost"]) if days_iter else 0.0, "burn_7d": _fmt(sum(v["cost"] for v in days_iter)), "spend_days": spend, "burn_src": spend_src, "coop": read_coop_structure(), } # ────────────────────────── симуляция сценариев ────────────────────────── def _model_params(config, productivity): volume = min(3.0, max(0.05, float(config.get("volume_mult", 1.0)))) flash = min(1.0, max(0.0, float(config.get("flash_share", 0.84)))) rate = flash * RATE_FLASH + (1 - flash) * RATE_FULL quality = flash * Q_FLASH + (1 - flash) * 1.0 return volume, rate, quality, productivity def calibrate(burn_hist: list, base_config: dict) -> float: """P бейзлайна: выручка = BASELINE_NET × средний burn (давление к оптимизации).""" mean_b = statistics.mean(burn_hist) or 100.0 _, rate, quality, _ = _model_params(base_config, 0.0) utility = (mean_b / rate) * quality return BASELINE_NET * mean_b / max(utility ** 0.5, 1e-9) def simulate(config: dict, burn_hist: list, productivity: float, days: int = HORIZON_DAYS, seed: int = 42, trials: int = 120) -> dict: """E[ΔAUM в горизонте days]: бюджет дня B=volume·burn_t покупает токены B/rate; полезность токенов×quality → выручка P·√польз; Δ = выручка − B.""" volume, rate, quality, P = _model_params(config, productivity) rng = random.Random(seed) finals = [] for _ in range(trials): aum = 0.0 for _d in range(days): B = volume * rng.choice(burn_hist) utility = (B / rate) * quality aum += P * (utility ** 0.5) - B finals.append(aum) return {"mean": statistics.mean(finals), "p10": statistics.quantiles(finals, n=10)[0]} def score(config, burn_hist, productivity, seed=42): return simulate(config, burn_hist, productivity, seed=seed)["mean"] # ────────────────────────── генетический отбор ────────────────────────── def evolve(burn_hist: list, pop_size: int = 12, generations: int = 6, seed: int = 7) -> dict: """Генетический отбор по fitness=E[ΔAUM]; песочница — отчёт-адвокат.""" rng = random.Random(seed) base_cfg = {"volume_mult": 1.0, "flash_share": 0.84} # «как сейчас» P = calibrate(burn_hist, base_cfg) base_fit = score(base_cfg, burn_hist, P, seed=seed) def mutate(c): return {"volume_mult": round(min(3.0, max(0.05, c["volume_mult"] + rng.gauss(0, 0.2))), 3), "flash_share": round(min(1.0, max(0.0, c["flash_share"] + rng.gauss(0, 0.12))), 3)} def crossover(a, b): return {"volume_mult": round((a["volume_mult"] + b["volume_mult"]) / 2, 3), "flash_share": round((a["flash_share"] + b["flash_share"]) / 2, 3)} pop = [{"volume_mult": round(rng.uniform(0.3, 1.8), 3), "flash_share": round(rng.uniform(0.2, 1.0), 3)} for _ in range(pop_size)] best, best_fit = base_cfg, base_fit history = [] for g in range(generations): scored = sorted(((score(c, burn_hist, P, seed=seed + g), c) for c in pop), reverse=True) if scored[0][0] > best_fit: best_fit, best = scored[0][0], dict(scored[0][1]) history.append(round(scored[0][0], 2)) elite = [c for _, c in scored[: max(2, pop_size // 3)]] pop = list(elite) while len(pop) < pop_size: pop.append(mutate(crossover(rng.choice(elite), rng.choice(elite)))) gap = (best_fit - base_fit) / abs(base_fit) * 100 if base_fit else 0.0 return { "baseline": base_cfg, "baseline_fitness": round(base_fit, 2), "best": best, "best_fitness": round(best_fit, 2), "improves": bool(best_fit > base_fit + 1e-9), "improvement_pct": round(gap, 2), "generations": generations, "pop_size": pop_size, "productivity_P": round(P, 4), "burn_mean_day": round(statistics.mean(burn_hist), 2), "fitness_history": history, "verdict": ("конфигурация лучше статус-кво — вынести дню/Alex'у " "(адвокат, не авто-применение)") if best_fit > base_fit + 1e-9 else "статус-кво остаётся", } def run_pilot(m: dict) -> dict: """Пилот на свежем замере: история burn → симуляции → отбор → вывод.""" hist = [v["cost"] for v in m.get("spend_days", {}).values()] or [100.0] return {"ts": m["ts"], "aum": m["aum"], "burn_7d": m.get("burn_7d"), "evolution": evolve(hist), "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") part = (f"AUM={aum} у.е." if aum is not None else "AUM пока не замерен (когда будет баланс — занеси в " "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} у.е.{trend} (прямой минус к AUM). Фильтр любого " f"решения и задачи: «добавляет ≥ +1% к AUM в горизонте год+?»; " f"короткий доход, съедающий долгосрочную базу — ниже приоритетом." f"{tail}")