388 lines
No EOL
20 KiB
Python
388 lines
No EOL
20 KiB
Python
"""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}") |