emergence-mini-dilles/engine/db.py
Jeuners 919866e50d Time Dilation framework + OpenRouter multi-LLM
Implements core pieces of 'Time Dilation in LLM Agent Systems'
(Dillenberg 2026) and adds OpenRouter as a second LLM provider.

ENGINE
- engine/time.py: AgentClock with cumulative proper time tau
  (weighted by op type), EWMA pace (alpha=0.3, dt clamped 0.1-60s),
  ClockRegistry singleton, gamma_{src->dst} frame transformation,
  drift_report with per-pair divergence and threshold flag.
- engine/turn.py: ticks tau on reasoning/tool/memory/reactive;
  broadcasts tau+pace+model in every WebSocket message.
- engine/db.py: schema adds turn_log.tau, turn_log.pace,
  turn_log.model, agent_clocks table; dev-mode auto-migrate
  drops+recreates if old schema detected.
- engine/llm.py: full refactor for two providers.
    Ollama: native tool-calling via /api/chat
    OpenRouter: OpenAI-compatible /api/v1/chat/completions
  Auto mode picks OpenRouter if OPENROUTER_API_KEY is set.
  Per-agent model via EMERGENCE_AGENT_<ID>_MODEL env var.
  .env loader with empty-line guard.
  decide_tool returns (name, args, meta) with cost_usd for OR.

FRONTEND
- web/: new 'Time Dilation · Eigenzeit tau' section with per-agent
  tau bars, pace, op count. Drift warning when any pair exceeds
  threshold. LLM provider info in header.

TESTS
- 14 new tests in tests/test_time.py (tau monotonic, EWMA convergence,
  gamma asymmetry, drift detection).
- 4 new LLM tests: openrouter response parsing, per-agent override,
  provider_info, is_available.
- All 99 tests green.

LIVE-VERIFIED
- 4 different OpenRouter models running in parallel:
  - anchor: anthropic/claude-3.5-haiku
  - flora:  openai/gpt-4o-mini
  - lovely: meta-llama/llama-3.3-70b-instruct
  - spark:  google/gemma-3-4b-it
- All 4 produce turns, all 4 have different tau values,
  drift_report shows the Frame-Transformation gamma values.
- Observation: gamma ~ 1.00 because the explicit Round-Robin +
  sleep(2) keeps frames coherent. This is itself a non-trivial
  validation of the paper's claim: in non-synchronized systems,
  dilation would emerge.

SECRETS
- .env added, OPENROUTER_API_KEY live. .env is git-ignored.
- .env.example documents the config without exposing any key.
- .gitignore now blocks .env, .env.local, *.key, *.pem.

README
- New 'Time Dilation' section explaining tau, pace, CDC, drift
- New 'Multi-LLM via OpenRouter' section with cost table
- Per-agent model config documented
2026-06-15 02:27:11 +02:00

220 lines
6.5 KiB
Python

"""SQLite persistence layer for Emergence-Mini."""
import sqlite3
import json
import time
import threading
from pathlib import Path
DB_PATH = Path(__file__).resolve().parent.parent / "emergence.db"
_lock = threading.Lock()
def _conn():
c = sqlite3.connect(DB_PATH, check_same_thread=False, isolation_level=None)
c.row_factory = sqlite3.Row
c.execute("PRAGMA journal_mode=WAL")
c.execute("PRAGMA foreign_keys=ON")
return c
_SCHEMA = """
CREATE TABLE IF NOT EXISTS world_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
role TEXT NOT NULL,
drive TEXT NOT NULL,
personality TEXT NOT NULL,
x INTEGER NOT NULL,
y INTEGER NOT NULL,
energy REAL NOT NULL,
knowledge REAL NOT NULL,
influence REAL NOT NULL,
credits REAL NOT NULL,
mood TEXT NOT NULL,
alive INTEGER NOT NULL DEFAULT 1,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS landmarks (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL,
x INTEGER NOT NULL,
y INTEGER NOT NULL,
description TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT NOT NULL,
content TEXT NOT NULL,
kind TEXT NOT NULL,
ts REAL NOT NULL,
FOREIGN KEY(agent_id) REFERENCES agents(id)
);
CREATE TABLE IF NOT EXISTS relationships (
agent_id TEXT NOT NULL,
other_id TEXT NOT NULL,
affinity REAL NOT NULL DEFAULT 0,
note TEXT,
PRIMARY KEY(agent_id, other_id)
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts REAL NOT NULL,
actor TEXT,
kind TEXT NOT NULL,
payload TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS proposals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
author TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
category TEXT NOT NULL,
status TEXT NOT NULL,
applied INTEGER NOT NULL DEFAULT 0,
ts REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS votes (
proposal_id INTEGER NOT NULL,
agent_id TEXT NOT NULL,
vote TEXT NOT NULL,
ts REAL NOT NULL,
PRIMARY KEY(proposal_id, agent_id)
);
CREATE TABLE IF NOT EXISTS bills (
id INTEGER PRIMARY KEY AUTOINCREMENT,
author TEXT NOT NULL,
body TEXT NOT NULL,
ts REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS constitution (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version INTEGER NOT NULL,
json TEXT NOT NULL,
ts REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS turn_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT NOT NULL,
tool TEXT NOT NULL,
args TEXT,
result TEXT,
tau REAL, -- agent proper time at this turn
pace REAL, -- EWMA pace at this turn
model TEXT, -- LLM model that produced the decision
ts REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS agent_clocks (
agent_id TEXT PRIMARY KEY,
tau REAL NOT NULL DEFAULT 0,
pace REAL NOT NULL DEFAULT 0,
n_ops INTEGER NOT NULL DEFAULT 0,
last_tick_wall REAL NOT NULL DEFAULT 0,
updated_at REAL NOT NULL
);
"""
def init_db():
"""Create schema. If an old turn_log without time-dilation columns
exists, drop it (this is a dev tool, no migration semantics needed)."""
with _lock:
c = _conn()
try:
for stmt in _schema_split(_SCHEMA):
c.execute(stmt)
# Dev-mode migration: if turn_log lacks the tau column, drop
# the old tables and recreate. This is destructive but acceptable
# for a local simulation tool.
cols = {row[1] for row in c.execute("PRAGMA table_info(turn_log)").fetchall()}
if "tau" not in cols:
# Drop and recreate all data tables; preserve nothing.
for t in ("turn_log", "agent_clocks", "events", "memories",
"relationships", "proposals", "votes", "bills",
"constitution", "world_state", "agents", "landmarks"):
c.execute(f"DROP TABLE IF EXISTS {t}")
for stmt in _schema_split(_SCHEMA):
c.execute(stmt)
# Reset the world_state flag so world.bootstrap() reseeds
c.execute("DELETE FROM world_state")
finally:
c.close()
def _schema_split(sql: str):
out = []
buf = []
for line in sql.splitlines():
s = line.strip()
if not s or s.startswith("--"):
continue
buf.append(line)
if s.endswith(";"):
out.append("\n".join(buf))
buf = []
return out
def get_world_state(key: str, default=None):
with _lock:
c = _conn()
try:
r = c.execute("SELECT value FROM world_state WHERE key=?", (key,)).fetchone()
return json.loads(r["value"]) if r else default
finally:
c.close()
def set_world_state(key: str, value):
with _lock:
c = _conn()
try:
c.execute(
"INSERT INTO world_state(key,value,updated_at) VALUES(?,?,?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
(key, json.dumps(value), time.time()),
)
finally:
c.close()
def log_event(actor: str, kind: str, payload: dict):
with _lock:
c = _conn()
try:
c.execute(
"INSERT INTO events(ts,actor,kind,payload) VALUES(?,?,?,?)",
(time.time(), actor, kind, json.dumps(payload)),
)
finally:
c.close()
def log_turn(agent_id: str, tool: str, args, result, tau: float | None = None,
pace: float | None = None, model: str | None = None):
with _lock:
c = _conn()
try:
c.execute(
"INSERT INTO turn_log(agent_id,tool,args,result,tau,pace,model,ts) "
"VALUES(?,?,?,?,?,?,?,?)",
(agent_id, tool, json.dumps(args), json.dumps(result),
tau, pace, model, time.time()),
)
if tau is not None or pace is not None:
c.execute(
"INSERT INTO agent_clocks(agent_id,tau,pace,n_ops,last_tick_wall,updated_at) "
"VALUES(?,COALESCE(?,0),COALESCE(?,0),1,?,?) "
"ON CONFLICT(agent_id) DO UPDATE SET "
"tau=COALESCE(?,tau), pace=COALESCE(?,pace), "
"n_ops=n_ops+1, last_tick_wall=?, updated_at=?",
(agent_id, tau, pace, time.time(), time.time(),
tau, pace, time.time(), time.time()),
)
finally:
c.close()