"""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.001022 # у.е./токен glm-5.3-flash (дайджест 25.09–02.10) RATE_FULL = 0.001058 # у.е./токен 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 млрд токенов флота ≈ 0.000943 у.е./токен. Порядок величины, не счёт. BURN_RATE_PER_TOKEN = float(os.environ.get( "SIMBIOT_BURN_RATE_PER_TOKEN", "0.000943")) 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": []} # ────────────────────────── замер ────────────────────────── 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 "недоступен" 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 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 "" 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"решения и задачи: «добавляет ≥ +1% к AUM в горизонте год+?»; " f"короткий доход, съедающий долгосрочную базу — ниже приоритетом." f"{tail}")