simbiot-harness/harness/aum.py

388 lines
No EOL
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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": []}
# ────────────────────────── замер ──────────────────────────
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}")