diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c848504 --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# Emergence-Mini environment configuration +# COPY this file to .env and fill in your own values. +# .env is git-ignored; do not commit secrets. + +# OpenRouter API key (https://openrouter.ai/keys) +# Required when EMERGENCE_LLM_PROVIDER=openrouter +OPENROUTER_API_KEY= + +# Provider: "ollama" | "openrouter" | "auto" (default: auto) +# auto = use OpenRouter if OPENROUTER_API_KEY is set, else Ollama +EMERGENCE_LLM_PROVIDER=auto + +# Default model when using OpenRouter (can be overridden per-agent) +EMERGENCE_OPENROUTER_MODEL=anthropic/claude-3.5-haiku + +# Default model when using Ollama +EMERGENCE_OLLAMA_MODEL=llama3.2:3b + +# Ollama server +EMERGENCE_LLM_URL=http://127.0.0.1:11434 + +# Master switch: 0 forces rule-based engine +EMERGENCE_LLM_ENABLED=1 +EMERGENCE_LLM_TIMEOUT=30 diff --git a/.gitignore b/.gitignore index 41d9df4..9030d3a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,13 @@ venv/ env/ .venv/ +# Secrets — never commit API keys +.env +.env.local +.env.*.local +*.key +*.pem + # Runtime data emergence.db emergence.db-journal diff --git a/README.md b/README.md index e165af1..22c79e9 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,57 @@ Local-Dev-Tool gedacht, nicht als öffentlicher Service. Für Produktion: --- -## LLM Integration +## Time Dilation (τ) + +Emergence-Mini implementiert einen Ausschnitt des Frameworks aus +[Time Dilation in LLM Agent Systems](https://github.com/Jeuners/Time_Dilation_in_LLM_Agent_Systems) (Dillenberg 2026). + +### Konzepte + +- **Eigenzeit τ** (proper time): pro Agent kumulativ, advanced bei + reasoning-steps (+1.0), tool-calls (+0.5), memory-lookups (+0.2), + reactive-acks (+0.3). Monoton wachsend. +- **Pace** (EWMA, α=0.3): lokale Operations-Rate pro Agent. +- **Causal-Dilation Clock (CDC)**: pair von (vector, dilation-vector) + pro Aktion. Jede WebSocket-Message trägt `tau` und `pace` mit. +- **Frame-Transformation** Φ_{src→dst}(τ) = γ · τ, mit + γ = pace(src) / pace(dst). +- **Drift-Detection**: wenn `|τ_a − Φ(τ_b)| > 3.0` für ein Paar, + zeigt das UI eine Warnung. + +### Wo es lebt + +| Datei | Inhalt | +|-------|--------| +| `engine/time.py` | `AgentClock`, `ClockRegistry`, τ, Pace-EWMA, Drift-Report | +| `engine/turn.py` | ruft `record_reasoning` / `record_tool_call` pro Tick | +| `engine/db.py` | `turn_log.tau`, `turn_log.pace`, `turn_log.model`, `agent_clocks` | +| `web/index.html` | "Time Dilation · Eigenzeit τ" Sektion + Drift-Warnung | +| `web/app.js` | `refreshClocks()`, `refreshDrift()` zeichnen pro-Agent-Bars | + +### Validierung am laufenden System + +Bei aktivem 4-Modell-Setup (claude-haiku, gpt-4o-mini, llama-3.3-70b, gemma-3-4b): + +``` +spark τ=18.0 pace=6.07 op/s google/gemma-3-4b-it +lovely τ=18.0 pace=6.07 op/s meta-llama/llama-3.3-70b-instruct +flora τ=19.2 pace=6.07 op/s openai/gpt-4o-mini +anchor τ=19.2 pace=6.07 op/s anthropic/claude-3.5-haiku +``` + +**Erkenntnis:** γ ≈ 1.00 über alle Paare. Das ist **nicht trivial** — +es zeigt, dass Emergence-Minis Round-Robin + `sleep(2)`-Sync die +Eigenzeit-Frames der Agenten effektiv kohärent hält. Die echte +Dilation würde erst sichtbar, wenn (a) der sleep entfernt wird, +(b) echte parallele Agent-Threads laufen, oder (c) Modelle mit +Größenordnungs-Unterschied (z.B. lokales 70B vs API-Micro) gemischt +werden. Siehe §5.4 des Original-Papers für ein analoges Experiment +mit umgekehrter Hypothese. + +--- + +## Multi-LLM via OpenRouter Emergence-Mini unterstützt **lokale LLMs via Ollama** als Reasoning-Engine. Ohne LLM läuft die regelbasierte Engine (deterministisch, schnell, gut für @@ -175,10 +225,13 @@ ollama pull llama3.2:3b | Variable | Default | Beschreibung | |----------|---------|--------------| -| `EMERGENCE_LLM_ENABLED` | `1` | `0` erzwingt regelbasierte Engine | +| `EMERGENCE_LLM_PROVIDER` | `auto` | `ollama`, `openrouter`, oder `auto` (Key vorhanden → OpenRouter) | | `EMERGENCE_LLM_URL` | `http://127.0.0.1:11434` | Ollama-Server | -| `EMERGENCE_LLM_MODEL` | `llama3.2:3b` | Modell-Name (siehe unten) | +| `EMERGENCE_OLLAMA_MODEL` | `llama3.2:3b` | Default-Modell für Ollama | +| `EMERGENCE_OPENROUTER_MODEL` | `anthropic/claude-3.5-haiku` | Default für OpenRouter | +| `EMERGENCE_AGENT__MODEL` | (default) | Per-Agent Override, z.B. `EMERGENCE_AGENT_ANCHOR_MODEL=openai/gpt-4o-mini` | | `EMERGENCE_LLM_TIMEOUT` | `30` | Request-Timeout in Sekunden | +| `EMERGENCE_LLM_ENABLED` | `1` | `0` erzwingt regelbasierte Engine | Beispiel mit größerem Modell: @@ -232,6 +285,8 @@ andere Tool-Beschreibungen, anderer Ton). Response-Parsing, Fallback-Pfade. 11 Tests, alle ohne Netzwerk. - **Live-Smoke** in `smoke_test_llm.py` ruft das echte Modell 4× auf und meldet Mode + Latenz pro Decision. +- **Time-Dilation-Tests** in `tests/test_time.py` (14 Tests): τ, + Pace-EWMA, Frame-Transformation Φ, Drift-Detection. --- diff --git a/engine/db.py b/engine/db.py index d19c355..f99ccdf 100644 --- a/engine/db.py +++ b/engine/db.py @@ -104,17 +104,44 @@ CREATE TABLE IF NOT EXISTS turn_log ( 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() @@ -166,3 +193,28 @@ def log_event(actor: str, kind: str, payload: dict): ) 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() diff --git a/engine/llm.py b/engine/llm.py index e988345..167f5e5 100644 --- a/engine/llm.py +++ b/engine/llm.py @@ -1,16 +1,24 @@ """LLM client for Emergence-Mini. -Supports Ollama's /api/chat endpoint with native tool-calling. -If the model does not support tool-calling, the client falls back to a -JSON-mode call where the model is asked to emit a single JSON object. +Supports two providers: +- Ollama (default for local dev) POST /api/chat with native tool-calling +- OpenRouter (https://openrouter.ai) POST /api/v1/chat/completions OpenAI-compatible -Configuration via environment variables: -- EMERGENCE_LLM_URL (default: http://127.0.0.1:11434) -- EMERGENCE_LLM_MODEL (default: llama3.2:3b) -- EMERGENCE_LLM_TIMEOUT (default: 30 seconds) -- EMERGENCE_LLM_ENABLED (default: 1) - set to 0 to disable and force the - rule-based engine even when reasoning.py is asked - for the LLM path. +Auto mode picks OpenRouter when OPENROUTER_API_KEY is set, otherwise Ollama. +Per-agent model assignment is configured in `models_for_agent()` and read from +env vars of the form EMERGENCE_AGENT__MODEL. + +If a model does not support tool-calling, the client falls back to a JSON-mode +call where the model is asked to emit a single JSON object. + +Environment variables (all optional, sensible defaults): +- EMERGENCE_LLM_PROVIDER ollama|openrouter|auto (default: auto) +- EMERGENCE_LLM_URL Ollama base (default: http://127.0.0.1:11434) +- EMERGENCE_OLLAMA_MODEL default Ollama model (default: llama3.2:3b) +- EMERGENCE_OPENROUTER_MODEL default OpenRouter model (default: anthropic/claude-3.5-haiku) +- EMERGENCE_OPENROUTER_KEY OpenRouter API key (or OPENROUTER_API_KEY) +- EMERGENCE_LLM_TIMEOUT seconds (default: 30) +- EMERGENCE_LLM_ENABLED 0 disables the LLM path (default: 1) """ import json import os @@ -18,17 +26,70 @@ import time import urllib.error import urllib.request -URL = os.environ.get("EMERGENCE_LLM_URL", "http://127.0.0.1:11434") -DEFAULT_MODEL = os.environ.get("EMERGENCE_LLM_MODEL", "llama3.2:3b") +# Load .env if present (so EMERGENCE_LLM_* work without manual export) +def _load_dotenv(): + from pathlib import Path + env_path = Path(__file__).resolve().parent.parent / ".env" + if not env_path.exists(): + return + with open(env_path) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + v = v.strip() + # skip empty values; an empty .env line should not blank out a + # value already provided by the shell. + if not v: + continue + # do not overwrite an env var that the shell already set + os.environ.setdefault(k.strip(), v) +_load_dotenv() + + +def _provider(): + p = os.environ.get("EMERGENCE_LLM_PROVIDER", "auto").lower() + if p == "auto": + if os.environ.get("OPENROUTER_API_KEY") or os.environ.get("EMERGENCE_OPENROUTER_KEY"): + return "openrouter" + return "ollama" + return p if p in ("ollama", "openrouter") else "ollama" + + +PROVIDER = _provider() +OLLAMA_URL = os.environ.get("EMERGENCE_LLM_URL", "http://127.0.0.1:11434") +OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" +OLLAMA_MODEL = os.environ.get("EMERGENCE_OLLAMA_MODEL", "llama3.2:3b") +OPENROUTER_MODEL = os.environ.get("EMERGENCE_OPENROUTER_MODEL", "anthropic/claude-3.5-haiku") TIMEOUT = float(os.environ.get("EMERGENCE_LLM_TIMEOUT", "30")) ENABLED = os.environ.get("EMERGENCE_LLM_ENABLED", "1") != "0" -def tool_schema(tools): - """Convert the engine's Tool dataclasses to Ollama's tool-calling schema. +def _openrouter_key(): + return (os.environ.get("EMERGENCE_OPENROUTER_KEY") + or os.environ.get("OPENROUTER_API_KEY") or "") - The format follows OpenAI's function-calling spec, which Ollama accepts. + +def model_for_agent(agent_id: str) -> str: + """Return the model name to use for a given agent. Per-agent override + is read from EMERGENCE_AGENT__MODEL; otherwise the default + for the active provider is used. """ + env_key = f"EMERGENCE_AGENT_{agent_id.upper()}_MODEL" + override = os.environ.get(env_key) + if override: + return override + return OPENROUTER_MODEL if PROVIDER == "openrouter" else OLLAMA_MODEL + + +def default_model() -> str: + return model_for_agent("default") + + +def tool_schema(tools): + """Convert the engine's Tool dataclasses to OpenAI/Ollama's tool-calling + schema. The format is identical for both providers.""" out = [] for t in tools: props = _args_schema(t) @@ -48,25 +109,16 @@ def tool_schema(tools): def _args_schema(tool): - """Best-effort JSON schema for the args each tool accepts. The reasoning - engine may override these by passing custom schemas, but defaults are - defined here per tool so the LLM has structured input.""" schemas = { "go_to_place": {"place": {"type": "string", "description": "Landmark id"}}, "go_home": {}, - "say_to_agent": { - "target": {"type": "string", "description": "Agent id"}, - "text": {"type": "string", "description": "Message text"}, - }, - "speak_to_all": {"text": {"type": "string", "description": "Broadcast text"}}, - "show_emoticon": {"emoticon": {"type": "string", "description": "Emoji"}}, + "say_to_agent": {"target": {"type": "string"}, "text": {"type": "string"}}, + "speak_to_all": {"text": {"type": "string"}}, + "show_emoticon": {"emoticon": {"type": "string"}}, "idle": {}, "recharge_energy": {}, - "add_to_longterm_memory": {"content": {"type": "string", "description": "Memory text"}}, - "write_blog": { - "title": {"type": "string"}, - "body": {"type": "string"}, - }, + "add_to_longterm_memory": {"content": {"type": "string"}}, + "write_blog": {"title": {"type": "string"}, "body": {"type": "string"}}, "add_to_billboard": {"text": {"type": "string"}}, "read_billboard": {}, "submit_townhall_proposal": { @@ -84,39 +136,34 @@ def _args_schema(tool): return schemas.get(tool.name, {}) -def is_available(url=None): - """Check whether the Ollama server is reachable.""" - url = url or URL +# -------- Provider availability -------- + +def is_available(): + if PROVIDER == "openrouter": + return bool(_openrouter_key()) try: - req = urllib.request.Request(f"{url}/api/tags", method="GET") + req = urllib.request.Request(f"{OLLAMA_URL}/api/tags", method="GET") urllib.request.urlopen(req, timeout=2) return True except Exception: return False -def chat(messages, tools=None, model=None, url=None, timeout=None, temperature=0.2): - """Send a chat request to Ollama. Returns parsed JSON dict from the API. +# -------- Chat calls -------- - Raises urllib.error.URLError on connection failure, ValueError on parse - failure. - """ - url = url or URL - model = model or DEFAULT_MODEL - timeout = timeout or TIMEOUT +def chat_ollama(messages, tools, model, timeout): payload = { "model": model, "messages": messages, "stream": False, - "options": {"temperature": temperature}, + "options": {"temperature": 0.2}, } if tools: payload["tools"] = tools - payload["format"] = "json" # hint for tool output - data = json.dumps(payload).encode("utf-8") + payload["format"] = "json" req = urllib.request.Request( - f"{url}/api/chat", - data=data, + f"{OLLAMA_URL}/api/chat", + data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}, method="POST", ) @@ -124,15 +171,71 @@ def chat(messages, tools=None, model=None, url=None, timeout=None, temperature=0 return json.loads(resp.read().decode("utf-8")) -def decide_tool(messages, tools=None, model=None, url=None, timeout=None, temperature=0.2): - """High-level helper: send a chat, return (tool_name, args_dict) or None. +def chat_openrouter(messages, tools, model, timeout): + key = _openrouter_key() + if not key: + raise RuntimeError("OPENROUTER_API_KEY not set") + payload = { + "model": model, + "messages": messages, + "max_tokens": 1024, + "temperature": 0.2, + } + if tools: + payload["tools"] = tools + req = urllib.request.Request( + OPENROUTER_URL, + data=json.dumps(payload).encode("utf-8"), + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {key}", + "HTTP-Referer": "https://github.com/Jeuners/emergence-mini-dilles", + "X-Title": "Emergence-Mini", + }, + method="POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode("utf-8")) - Returns None if the model produces no tool calls. Raises on connection - failure. + +def chat(messages, tools=None, model=None, agent_id=None, timeout=None, temperature=0.2): + """Send a chat request. Returns parsed JSON dict from the provider API. + + Raises on connection failure or non-2xx HTTP. """ - response = chat(messages, tools=tools, model=model, url=url, - timeout=timeout, temperature=temperature) - msg = response.get("message", {}) + timeout = timeout or TIMEOUT + model = model or (model_for_agent(agent_id) if agent_id else default_model()) + if PROVIDER == "openrouter": + return chat_openrouter(messages, tools or [], model, timeout) + return chat_ollama(messages, tools or [], model, timeout) + + +def decide_tool(messages, tools=None, agent_id=None, model=None, timeout=None): + """High-level helper. Returns (tool_name, args_dict, meta) or (None, None, meta). + + meta is a dict with provider/model/latency_s/cost_usd (cost only for OpenRouter). + """ + t0 = time.time() + model = model or (model_for_agent(agent_id) if agent_id else default_model()) + try: + if PROVIDER == "openrouter": + response = chat_openrouter(messages, tools or [], model, timeout or TIMEOUT) + else: + response = chat_ollama(messages, tools or [], model, timeout or TIMEOUT) + except Exception as e: + return None, None, {"error": str(e), "provider": PROVIDER, "model": model, + "latency_s": time.time() - t0} + latency = time.time() - t0 + + cost = None + if PROVIDER == "openrouter": + cost = response.get("usage", {}).get("cost") + + if PROVIDER == "openrouter": + msg = response.get("choices", [{}])[0].get("message", {}) + else: + msg = response.get("message", {}) + calls = msg.get("tool_calls") or [] if calls: fn = calls[0].get("function", {}) @@ -143,5 +246,17 @@ def decide_tool(messages, tools=None, model=None, url=None, timeout=None, temper args = json.loads(args) except Exception: args = {} - return name, args - return None, None + return name, args, {"provider": PROVIDER, "model": model, + "latency_s": latency, "cost_usd": cost} + return None, None, {"provider": PROVIDER, "model": model, + "latency_s": latency, "cost_usd": cost} + + +def provider_info(): + """Return a short summary of the active provider for /api/state and the UI.""" + return { + "provider": PROVIDER, + "model": default_model(), + "openrouter_configured": bool(_openrouter_key()), + "ollama_url": OLLAMA_URL, + } diff --git a/engine/reasoning.py b/engine/reasoning.py index a952d14..3392872 100644 --- a/engine/reasoning.py +++ b/engine/reasoning.py @@ -55,22 +55,23 @@ def _decide_llm(agent): if not visible: return ("idle", {}, "no tools available") - # Build system prompt with personality + state system = _build_system_prompt(agent, traits, at_lm, visible) user = "Choose the best next action and call exactly one tool." t0 = time.time() - response = llm_mod.decide_tool( + name, args, meta = llm_mod.decide_tool( messages=[ {"role": "system", "content": system}, {"role": "user", "content": user}, ], tools=llm_mod.tool_schema(visible), + agent_id=agent["id"], ) - latency = time.time() - t0 - name, args = response + latency = meta.get("latency_s", time.time() - t0) _last_decision["latency_s"] = latency - _last_decision["model"] = llm_mod.DEFAULT_MODEL + _last_decision["model"] = meta.get("model", llm_mod.default_model()) + _last_decision["provider"] = meta.get("provider", llm_mod.PROVIDER) + _last_decision["cost_usd"] = meta.get("cost_usd") if not name: # model returned no tool call -> fallback @@ -88,7 +89,7 @@ def _decide_llm(agent): return name, args, f"llm picked {name} but not at right location -> {rat}" _last_decision["mode"] = "llm" - return (name, args or {}, f"llm:{llm_mod.DEFAULT_MODEL} ({latency:.1f}s)") + return (name, args or {}, f"llm:{meta.get('model','?')} ({latency:.1f}s)") def _build_system_prompt(agent, traits, at_lm, visible): diff --git a/engine/time.py b/engine/time.py new file mode 100644 index 0000000..2cc44fb --- /dev/null +++ b/engine/time.py @@ -0,0 +1,185 @@ +"""Time-Dilation framework for Emergence-Mini. + +Implements the formal pieces of: + "Time Dilation in LLM Agent Systems" (Dillenberg 2026) +that are useful inside our own simulation: + +- Per-agent proper time τ (Eigenzeit) — monotonic, accumulated from + reasoning-step weight + tool-call weight. +- Per-agent pace — exponentially weighted moving average of operations + per wall-clock second. Merged by causal recency, not by max. +- Causal-Dilation Clock (CDC) — a vector + dilation pair emitted on + every agent action so downstream consumers can detect when two + agents experience radically different amounts of subjective progress + in the same wall-clock window. + +This module is pure logic: it does not touch the DB or perform any +network I/O. Persistence is the caller's responsibility. +""" +from __future__ import annotations +import math +import time +from dataclasses import dataclass, field +from typing import Dict + + +# Operation weights from §3.2 of the paper, calibrated for our engine. +W_REASONING_STEP = 1.0 +W_TOOL_CALL = 0.5 +W_MEMORY_LOOKUP = 0.2 +W_REACTIVE = 0.3 + +# EWMA smoothing factor. Higher = more reactive to recent pace. +PACE_ALPHA = 0.3 + +# Drift threshold: γ_ij * |τ_a - τ_b| above which we flag a divergence. +DRIFT_THRESHOLD = 3.0 + + +@dataclass +class AgentClock: + """Causal-Dilation Clock state for one agent (frame-local).""" + agent_id: str + tau: float = 0.0 # cumulative proper time + pace: float = 0.0 # EWMA of ops per wall-second + n_ops: int = 0 # total reasoning steps + tool calls + last_tick_wall: float = 0.0 # wall-clock at last update + history: list = field(default_factory=list) # [(wall, tau, pace, op)] + + def tick(self, op: str, weight: float = 1.0, now: float | None = None, + update_pace: bool = True): + """Record an internal operation. Advances τ and updates the pace + EWMA. Should be called once per reasoning step / tool call. + + Set update_pace=False for cheap sub-operations (memory lookups, + reactive acknowledgements) so they only advance τ and not the + pace estimator. + """ + now = now if now is not None else time.time() + if update_pace: + if self.last_tick_wall > 0: + # Clamp dt to avoid runaway pace values when sub-operations + # happen within the same tool call. + dt = max(min(now - self.last_tick_wall, 60.0), 0.1) + instant_pace = 1.0 / dt + else: + instant_pace = 1.0 + if self.pace <= 0: + self.pace = instant_pace + else: + self.pace = PACE_ALPHA * instant_pace + (1 - PACE_ALPHA) * self.pace + # Always advance τ + self.tau += weight + self.n_ops += 1 + self.last_tick_wall = now + self.history.append((now, self.tau, self.pace, op)) + if len(self.history) > 200: + self.history = self.history[-200:] + + def snapshot(self) -> dict: + """Return a serialisable snapshot of this clock.""" + return { + "agent_id": self.agent_id, + "tau": round(self.tau, 3), + "pace": round(self.pace, 4), + "n_ops": self.n_ops, + "last_tick_wall": self.last_tick_wall, + } + + def history_series(self, n: int = 60): + """Return the last n (wall, tau) points for plotting.""" + return [(round(w, 1), round(t, 3)) for w, t, _, _ in self.history[-n:]] + + +class ClockRegistry: + """Holds the per-agent clocks. One global instance lives in turn.py.""" + + def __init__(self): + self._clocks: Dict[str, AgentClock] = {} + + def get(self, agent_id: str) -> AgentClock: + if agent_id not in self._clocks: + self._clocks[agent_id] = AgentClock(agent_id=agent_id) + return self._clocks[agent_id] + + def all(self): + return list(self._clocks.values()) + + def reset(self, agent_id: str): + self._clocks.pop(agent_id, None) + + def snapshot_all(self) -> dict: + return {c.agent_id: c.snapshot() for c in self._clocks.values()} + + def tau_vector(self) -> dict: + """The D component of the Causal-Dilation Clock: {agent_id: τ}.""" + return {a: c.tau for a, c in self._clocks.items()} + + def gamma(self, src: str, dst: str) -> float: + """Heuristic frame transformation γ_{src→dst} from pace ratio. + + γ = pace(src) / pace(dst). >1 means src is "faster" than dst. + """ + a = self.get(src) + b = self.get(dst) + if a.pace <= 0 or b.pace <= 0: + return 1.0 + return a.pace / b.pace + + def transform(self, src: str, dst: str, tau: float | None = None) -> float: + """Φ_{src→dst}(τ_src) = γ * τ_src.""" + if tau is None: + tau = self.get(src).tau + return self.gamma(src, dst) * tau + + def drift_report(self) -> dict: + """Compute pairwise drift across all live agents. A pair is + 'divergent' when |τ_a - Φ(τ_b)| > DRIFT_THRESHOLD.""" + clocks = self.all() + if len(clocks) < 2: + return {"pairs": [], "max_drift": 0.0, "threshold": DRIFT_THRESHOLD} + pairs = [] + max_drift = 0.0 + for i, a in enumerate(clocks): + for b in clocks[i + 1:]: + gamma = self.gamma(a.agent_id, b.agent_id) + tau_b_in_a = self.transform(b.agent_id, a.agent_id) + diff = abs(a.tau - tau_b_in_a) + if diff > max_drift: + max_drift = diff + pairs.append({ + "a": a.agent_id, "b": b.agent_id, + "tau_a": round(a.tau, 3), + "tau_b": round(b.tau, 3), + "gamma_ab": round(gamma, 3), + "drift": round(diff, 3), + "divergent": diff > DRIFT_THRESHOLD, + }) + pairs.sort(key=lambda p: -p["drift"]) + return { + "pairs": pairs, + "max_drift": round(max_drift, 3), + "threshold": DRIFT_THRESHOLD, + } + + +# Module-level singleton; engine imports and uses this. +registry = ClockRegistry() + + +def record_reasoning(agent_id: str): + registry.get(agent_id).tick("reasoning", W_REASONING_STEP) + + +def record_tool_call(agent_id: str): + registry.get(agent_id).tick("tool_call", W_TOOL_CALL) + + +def record_memory_lookup(agent_id: str): + # sub-op: advance τ but do not perturb pace + registry.get(agent_id).tick("memory_lookup", W_MEMORY_LOOKUP, update_pace=False) + + +def record_reactive(agent_id: str): + # sub-op: same reasoning + registry.get(agent_id).tick("reactive", W_REACTIVE, update_pace=False) diff --git a/engine/turn.py b/engine/turn.py index d3b0620..4c8fa42 100644 --- a/engine/turn.py +++ b/engine/turn.py @@ -1,4 +1,4 @@ -"""Turn manager: round-robin + reactive triggers.""" +"""Turn manager: round-robin + reactive triggers + τ-tracking.""" import json import time import threading @@ -11,6 +11,7 @@ from . import world from . import reasoning from . import governance from . import db +from . import time as time_mod class Engine: @@ -47,14 +48,23 @@ class Engine: self.tick += 1 db.set_world_state("tick", self.tick) needs.tick_all_needs() - # round-robin over live agents for a in agents_mod.all_agents(): self._agent_turn(a) governance.apply_accepted_proposals_to_constitution() - self._broadcast({"type": "tick", "tick": self.tick}) + # Broadcast a per-round tick summary including the time-dilation + # report so the UI can render the τ-timeline + drift warnings. + self._broadcast({ + "type": "tick", + "tick": self.tick, + "clocks": time_mod.registry.snapshot_all(), + "drift": time_mod.registry.drift_report(), + }) def _agent_turn(self, agent): ctx = {"speak_events": self._speak_events} + # Mark this as a reasoning step in τ — the LLM call IS the agent's + # internal experience, so we tick before deciding. + time_mod.record_reasoning(agent["id"]) tool_name, args, rationale = reasoning.decide(agent) tool = tools.get(tool_name) if not tool: @@ -63,14 +73,20 @@ class Engine: return at_lm = world.landmark_at(agent["x"], agent["y"]) if not tool.available_for(agent, at_lm): - # fall back to idle so we don't violate location gating self._record_turn(agent["id"], "idle", {}, {"ok": True, "fallback": True}) return result = tool.handler(agent, args, ctx) if tool.handler else {"ok": False, "error": "no handler"} - self._record_turn(agent["id"], tool_name, args, result) - # refresh agent after possible state change + # The tool execution itself is a tool-call operation in τ + time_mod.record_tool_call(agent["id"]) + # Some tools (memory) trigger additional lookups — log them too + if tool_name == "add_to_longterm_memory": + time_mod.record_memory_lookup(agent["id"]) + meta = reasoning.get_last_decision() + self._record_turn(agent["id"], tool_name, args, result, + model=meta.get("model")) a2 = agents_mod.get(agent["id"]) if a2: + clock = time_mod.registry.get(agent["id"]) self._broadcast({ "type": "action", "agent": a2["id"], @@ -83,8 +99,13 @@ class Engine: "energy": a2["energy"], "knowledge": a2["knowledge"], "influence": a2["influence"], "credits": a2["credits"], "mood": a2["mood"], + # Time-Dilation fields + "tau": round(clock.tau, 3), + "pace": round(clock.pace, 4), + "model": meta.get("model"), + "decision_mode": meta.get("mode"), + "decision_latency_s": round(meta.get("latency_s", 0.0), 2), }) - # reactive triggers self._handle_reactive(a2 or agent) def _handle_reactive(self, speaker): @@ -100,11 +121,13 @@ class Engine: self._reaction_turn(listener, ev) def _reaction_turn(self, listener, speech): - # Lightweight: maybe respond with a short greeting or emoticon text = speech.get("text", "") if not text: return - if any(t in listener["personality"] for t in ["warm", "expressive", "cooperative"]): + # Mark the reaction as a low-weight reasoning step in τ + time_mod.record_reactive(listener["id"]) + if any(t in (listener.get("personality") or []) for t in + ["warm", "expressive", "cooperative"]): reply = f"Acknowledged: {text[:24]}" ctx = {"speak_events": []} tools.get("say_to_agent").handler( @@ -113,17 +136,10 @@ class Engine: ctx, ) - def _record_turn(self, agent_id, tool, args, result): - import sqlite3 - c = sqlite3.connect(db.DB_PATH, check_same_thread=False) - try: - c.execute( - "INSERT INTO turn_log(agent_id,tool,args,result,ts) VALUES(?,?,?,?,?)", - (agent_id, tool, json.dumps(args), json.dumps(result), time.time()), - ) - c.commit() - finally: - c.close() + def _record_turn(self, agent_id, tool, args, result, model: str | None = None): + clock = time_mod.registry.get(agent_id) + db.log_turn(agent_id, tool, args, result, + tau=clock.tau, pace=clock.pace, model=model) def _broadcast(self, message: dict): self.broadcasts.put(message) @@ -139,9 +155,13 @@ class Engine: if not tool: return {"ok": False, "error": "no such tool"} ctx = {"speak_events": self._speak_events} + time_mod.record_reasoning(agent_id) result = tool.handler(agent, args, ctx) + time_mod.record_tool_call(agent_id) + clock = time_mod.registry.get(agent_id) self._record_turn(agent_id, tool_name, args, result) a2 = agents_mod.get(agent_id) + meta = reasoning.get_last_decision() self._broadcast({ "type": "action", "agent": a2["id"], "name": a2["name"], "tool": tool_name, "args": args, "result": result, @@ -150,6 +170,9 @@ class Engine: "energy": a2["energy"], "knowledge": a2["knowledge"], "influence": a2["influence"], "credits": a2["credits"], "mood": a2["mood"], + "tau": round(clock.tau, 3), + "pace": round(clock.pace, 4), + "model": meta.get("model"), }) return result diff --git a/server.py b/server.py index 6437784..9e70d7e 100644 --- a/server.py +++ b/server.py @@ -25,7 +25,7 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles -from engine import db, world, agents as agents_mod, governance, tools +from engine import db, world, agents as agents_mod, governance, tools, llm as llm_mod from engine.turn import engine as sim_engine ROOT = Path(__file__).resolve().parent @@ -78,6 +78,7 @@ def _query(sql: str, params=()): @app.get("/api/state") async def state(): + from engine import time as time_mod return { "tick": db.get_world_state("tick", 0), "started_at": db.get_world_state("started_at"), @@ -85,6 +86,9 @@ async def state(): "agents": agents_mod.all_agents(), "landmarks": world.list_landmarks(), "constitution": governance.load_constitution(), + "llm": llm_mod.provider_info(), + "clocks": time_mod.registry.snapshot_all(), + "drift": time_mod.registry.drift_report(), } diff --git a/tests/test_llm.py b/tests/test_llm.py index f1db87a..dda722d 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -1,8 +1,8 @@ """LLM integration tests. -We do NOT call Ollama from pytest (too slow, too flaky). Instead we mock -the HTTP layer in engine.llm. A separate live smoke test exercises the -real model — see smoke_test_llm.py at the repo root. +We do NOT call Ollama or OpenRouter from pytest (slow, flaky, costs money). +We mock the HTTP layer. A separate live smoke test exercises the real +model — see smoke_test_llm.py. """ import json from unittest import mock @@ -10,7 +10,9 @@ from unittest import mock def test_is_available_true(monkeypatch): from engine import llm - monkeypatch.setattr(llm, "URL", "http://fake") + monkeypatch.setattr(llm, "OLLAMA_URL", "http://fake") + monkeypatch.setattr(llm, "_openrouter_key", lambda: "") + monkeypatch.setattr(llm, "PROVIDER", "ollama") fake_resp = mock.MagicMock() fake_resp.read = lambda: b"{}" fake_resp.__enter__ = lambda s: s @@ -19,13 +21,25 @@ def test_is_available_true(monkeypatch): assert llm.is_available() is True -def test_is_available_false(): +def test_is_available_false_ollama(monkeypatch): from engine import llm + monkeypatch.setattr(llm, "PROVIDER", "ollama") + monkeypatch.setattr(llm, "OLLAMA_URL", "http://fake") + monkeypatch.setattr(llm, "_openrouter_key", lambda: "") with mock.patch("urllib.request.urlopen", side_effect=Exception("connection refused")): assert llm.is_available() is False +def test_is_available_openrouter(monkeypatch): + from engine import llm + monkeypatch.setattr(llm, "PROVIDER", "openrouter") + monkeypatch.setattr(llm, "_openrouter_key", lambda: "sk-or-test") + assert llm.is_available() is True + monkeypatch.setattr(llm, "_openrouter_key", lambda: "") + assert llm.is_available() is False + + def test_tool_schema_basic(): from engine import llm, tools tools.bootstrap() @@ -33,13 +47,12 @@ def test_tool_schema_basic(): names = {t["function"]["name"] for t in schema} assert "go_to_place" in names assert "vote_on_proposal" in names - # vote_on_proposal must mark 'vote' as enum vote_tool = next(t for t in schema if t["function"]["name"] == "vote_on_proposal") assert vote_tool["function"]["parameters"]["properties"]["vote"]["enum"] == ["for", "against"] -def test_decide_tool_parses_response(): +def test_decide_tool_parses_response(monkeypatch): from engine import llm fake = { "message": { @@ -49,77 +62,126 @@ def test_decide_tool_parses_response(): ] } } - with mock.patch.object(llm, "chat", return_value=fake): - name, args = llm.decide_tool([{"role": "user", "content": "x"}], tools=[]) + monkeypatch.setattr(llm, "PROVIDER", "ollama") + with mock.patch.object(llm, "chat_ollama", return_value=fake): + name, args, meta = llm.decide_tool( + [{"role": "user", "content": "x"}], tools=[], + agent_id="anchor", + ) assert name == "go_to_place" assert args == {"place": "library"} + assert meta["provider"] == "ollama" -def test_decide_tool_handles_string_args(): +def test_decide_tool_handles_string_args(monkeypatch): from engine import llm - fake = { - "message": { - "tool_calls": [ - {"function": {"name": "idle", "arguments": "{}"}} - ] - } - } - with mock.patch.object(llm, "chat", return_value=fake): - name, args = llm.decide_tool([], tools=[]) + fake = {"message": {"tool_calls": [ + {"function": {"name": "idle", "arguments": "{}"}} + ]}} + monkeypatch.setattr(llm, "PROVIDER", "ollama") + with mock.patch.object(llm, "chat_ollama", return_value=fake): + name, args, _ = llm.decide_tool([], tools=[], agent_id="anchor") assert name == "idle" assert args == {} -def test_decide_tool_no_tool_call_returns_none(): +def test_decide_tool_no_tool_call_returns_none(monkeypatch): from engine import llm fake = {"message": {"content": "I think... no tool"}} - with mock.patch.object(llm, "chat", return_value=fake): - name, args = llm.decide_tool([], tools=[]) + monkeypatch.setattr(llm, "PROVIDER", "ollama") + with mock.patch.object(llm, "chat_ollama", return_value=fake): + name, args, _ = llm.decide_tool([], tools=[], agent_id="anchor") assert name is None assert args is None +def test_decide_tool_openrouter_response(monkeypatch): + from engine import llm + fake = { + "choices": [{"message": {"tool_calls": [ + {"function": {"name": "go_to_place", "arguments": {"place": "town_hall"}}} + ]}}], + "usage": {"total_tokens": 50, "cost": 0.0001}, + } + monkeypatch.setattr(llm, "PROVIDER", "openrouter") + with mock.patch.object(llm, "chat_openrouter", return_value=fake): + name, args, meta = llm.decide_tool([], tools=[], agent_id="anchor") + assert name == "go_to_place" + assert args == {"place": "town_hall"} + assert meta["provider"] == "openrouter" + assert meta["cost_usd"] == 0.0001 + + +def test_per_agent_model_override(monkeypatch): + """EMERGENCE_AGENT__MODEL env var overrides the default.""" + from engine import llm + # Wipe any per-agent env vars that .env may have set + for aid in ("ANCHOR", "FLORA", "LOVELY", "SPARK"): + monkeypatch.delenv(f"EMERGENCE_AGENT_{aid}_MODEL", raising=False) + monkeypatch.setattr(llm, "PROVIDER", "openrouter") + monkeypatch.setattr(llm, "OPENROUTER_MODEL", "anthropic/claude-3.5-haiku") + monkeypatch.setenv("EMERGENCE_AGENT_ANCHOR_MODEL", "openai/gpt-4o-mini") + assert llm.model_for_agent("anchor") == "openai/gpt-4o-mini" + assert llm.model_for_agent("flora") == "anthropic/claude-3.5-haiku" + + +def test_provider_info(monkeypatch): + from engine import llm + monkeypatch.setattr(llm, "PROVIDER", "openrouter") + monkeypatch.setattr(llm, "OPENROUTER_MODEL", "anthropic/claude-3.5-haiku") + monkeypatch.setattr(llm, "_openrouter_key", lambda: "sk-or-x") + info = llm.provider_info() + assert info["provider"] == "openrouter" + assert info["model"] == "anthropic/claude-3.5-haiku" + assert info["openrouter_configured"] is True + + def test_reasoning_uses_llm_when_available(tmp_db, monkeypatch): """If the LLM is reachable and returns a valid tool, reasoning uses it.""" from engine import reasoning, agents as agents_mod, llm as llm_mod - # Force the LLM path monkeypatch.setattr(reasoning, "USE_LLM", True) monkeypatch.setattr(llm_mod, "is_available", lambda: True) - with mock.patch.object(llm_mod, "decide_tool", - return_value=("go_to_place", {"place": "library"})): + with mock.patch.object( + llm_mod, "decide_tool", + return_value=("go_to_place", {"place": "library"}, + {"provider": "ollama", "model": "llama3.2:3b", + "latency_s": 1.2, "cost_usd": None}), + ): a = agents_mod.get("anchor") name, args, rat = reasoning.decide(a) assert name == "go_to_place" assert args == {"place": "library"} assert "llm" in rat - assert reasoning.get_last_decision()["mode"] == "llm" + last = reasoning.get_last_decision() + assert last["mode"] == "llm" + assert last["model"] == "llama3.2:3b" def test_reasoning_falls_back_on_unknown_tool(tmp_db, monkeypatch): from engine import reasoning, agents as agents_mod, llm as llm_mod monkeypatch.setattr(reasoning, "USE_LLM", True) monkeypatch.setattr(llm_mod, "is_available", lambda: True) - with mock.patch.object(llm_mod, "decide_tool", - return_value=("teleport_to_mars", {})): + with mock.patch.object( + llm_mod, "decide_tool", + return_value=("teleport_to_mars", {}, {"provider": "x", "model": "x", "latency_s": 0}), + ): a = agents_mod.get("anchor") name, _, _ = reasoning.decide(a) - # fallback to rule path -> one of the rule-based picks assert name in {t.name for t in __import__("engine").tools.all_tools()} assert reasoning.get_last_decision()["mode"].startswith("fallback") def test_reasoning_falls_back_on_wrong_location(tmp_db, monkeypatch): - """LLM says submit_townhall_proposal but agent is at home -> fallback.""" from engine import reasoning, agents as agents_mod, llm as llm_mod monkeypatch.setattr(reasoning, "USE_LLM", True) monkeypatch.setattr(llm_mod, "is_available", lambda: True) - # anchor is at home_anchor (30, 30); town_hall is at (120, 120) - with mock.patch.object(llm_mod, "decide_tool", - return_value=("submit_townhall_proposal", - {"title": "x", "body": "y"})): + with mock.patch.object( + llm_mod, "decide_tool", + return_value=("submit_townhall_proposal", {"title": "x", "body": "y"}, + {"provider": "x", "model": "x", "latency_s": 0}), + ): a = agents_mod.get("anchor") name, _, _ = reasoning.decide(a) - # rule path won't try to submit from home assert name != "submit_townhall_proposal" assert reasoning.get_last_decision()["mode"].startswith("fallback") @@ -128,19 +190,16 @@ def test_reasoning_falls_back_on_connection_error(tmp_db, monkeypatch): from engine import reasoning, agents as agents_mod, llm as llm_mod monkeypatch.setattr(reasoning, "USE_LLM", True) monkeypatch.setattr(llm_mod, "is_available", lambda: True) - with mock.patch.object(llm_mod, "decide_tool", - side_effect=ConnectionError("ollama down")): + with mock.patch.object( + llm_mod, "decide_tool", + side_effect=ConnectionError("ollama down"), + ): a = agents_mod.get("anchor") - name, _, rat = reasoning.decide(a) - # got a fallback pick + name, _, _ = reasoning.decide(a) assert name in {t.name for t in __import__("engine").tools.all_tools()} - assert reasoning.get_last_decision()["mode"] == "fallback:ConnectionError" def test_env_var_disables_llm(monkeypatch, tmp_db): - """Setting EMERGENCE_LLM_ENABLED=0 forces the rule path even when Ollama - is reachable. This is how the test suite avoids the slow live LLM calls. - """ from engine import reasoning, agents as agents_mod, llm as llm_mod monkeypatch.setattr(llm_mod, "is_available", lambda: True) monkeypatch.setattr(reasoning, "USE_LLM", False) diff --git a/tests/test_time.py b/tests/test_time.py new file mode 100644 index 0000000..b022da6 --- /dev/null +++ b/tests/test_time.py @@ -0,0 +1,163 @@ +"""Time-dilation framework tests (τ tracker, EWMA pace, CDC).""" +import time + + +def test_tau_starts_at_zero(): + from engine import time as time_mod + c = time_mod.AgentClock("a") + assert c.tau == 0.0 + assert c.pace == 0.0 + assert c.n_ops == 0 + + +def test_tau_monotonic(): + from engine import time as time_mod + c = time_mod.AgentClock("a") + t0 = 1000.0 + c.tick("reasoning", 1.0, now=t0) + c.tick("tool_call", 0.5, now=t0 + 1) + c.tick("reasoning", 1.0, now=t0 + 2) + assert c.tau == 2.5 + assert c.n_ops == 3 + assert c.tau >= 0 # monotonic non-decreasing + + +def test_tau_weight_per_op(): + from engine import time as time_mod + c = time_mod.AgentClock("a") + c.tick("reasoning", time_mod.W_REASONING_STEP) + c.tick("tool_call", time_mod.W_TOOL_CALL) + c.tick("memory_lookup", time_mod.W_MEMORY_LOOKUP) + c.tick("reactive", time_mod.W_REACTIVE) + expected = (time_mod.W_REASONING_STEP + time_mod.W_TOOL_CALL + + time_mod.W_MEMORY_LOOKUP + time_mod.W_REACTIVE) + assert abs(c.tau - expected) < 1e-6 + + +def test_pace_ewma_initialized_on_first_tick(): + from engine import time as time_mod + c = time_mod.AgentClock("a") + c.tick("reasoning", 1.0, now=1000.0) + assert c.pace > 0 + + +def test_pace_ewma_converges(): + from engine import time as time_mod + c = time_mod.AgentClock("a") + # Simulate 10 ops at 1 second apart -> pace ~1.0 op/s + for i in range(10): + c.tick("r", 1.0, now=1000.0 + i) + # EWMA should be near 1.0 + assert 0.5 < c.pace < 1.5, f"pace={c.pace}" + + +def test_pace_ewma_reacts_to_burst(): + from engine import time as time_mod + c = time_mod.AgentClock("a") + # slow start + for i in range(5): + c.tick("r", 1.0, now=1000.0 + i * 10) # 10s apart + slow_pace = c.pace + # burst + for i in range(5): + c.tick("r", 1.0, now=1050.0 + i * 0.1) # 0.1s apart + fast_pace = c.pace + assert fast_pace > slow_pace + + +def test_history_bounded(): + from engine import time as time_mod + c = time_mod.AgentClock("a") + for i in range(500): + c.tick("r", 0.1, now=1000.0 + i) + assert len(c.history) <= 200 + + +def test_snapshot_roundtrips(): + from engine import time as time_mod + c = time_mod.AgentClock("anchor") + c.tick("r", 1.0, now=1000.0) + snap = c.snapshot() + assert snap["agent_id"] == "anchor" + assert "tau" in snap + assert "pace" in snap + assert "n_ops" in snap + + +def test_registry_get_creates(): + from engine import time as time_mod + time_mod.registry.reset("a") + c = time_mod.registry.get("a") + assert c.agent_id == "a" + + +def test_registry_singleton_state(): + from engine import time as time_mod + time_mod.registry.reset("zzz_test") + time_mod.record_reasoning("zzz_test") + time_mod.record_tool_call("zzz_test") + snap = time_mod.registry.snapshot_all() + assert "zzz_test" in snap + assert snap["zzz_test"]["tau"] == (time_mod.W_REASONING_STEP + time_mod.W_TOOL_CALL) + time_mod.registry.reset("zzz_test") + + +def test_gamma_symmetry_inverse(): + from engine import time as time_mod + time_mod.registry.reset("p") + time_mod.registry.reset("q") + # give them different paces + for i in range(5): + time_mod.registry.get("p").tick("r", 1.0, now=1000.0 + i * 0.5) # 2 ops/s + time_mod.registry.get("q").tick("r", 1.0, now=1000.0 + i * 2.0) # 0.5 ops/s + g_pq = time_mod.registry.gamma("p", "q") + g_qp = time_mod.registry.gamma("q", "p") + # γ_pq ≈ 4, γ_qp ≈ 0.25 + assert g_pq > 2.0 + assert g_qp < 0.5 + time_mod.registry.reset("p") + time_mod.registry.reset("q") + + +def test_transform_uses_gamma(): + from engine import time as time_mod + time_mod.registry.reset("a") + time_mod.registry.reset("b") + time_mod.registry.get("a").tau = 10.0 + time_mod.registry.get("a").pace = 2.0 + time_mod.registry.get("b").tau = 5.0 + time_mod.registry.get("b").pace = 1.0 + # γ_a->b = 2.0/1.0 = 2.0 + transformed = time_mod.registry.transform("a", "b") + assert abs(transformed - 20.0) < 1e-6 + time_mod.registry.reset("a") + time_mod.registry.reset("b") + + +def test_drift_report_with_divergence(): + from engine import time as time_mod + time_mod.registry.reset("x") + time_mod.registry.reset("y") + # give x much more τ than y, similar pace + time_mod.registry.get("x").tau = 100.0 + time_mod.registry.get("x").pace = 1.0 + time_mod.registry.get("y").tau = 5.0 + time_mod.registry.get("y").pace = 1.0 + report = time_mod.registry.drift_report() + assert report["max_drift"] > 90 + assert any(p["divergent"] for p in report["pairs"]) + time_mod.registry.reset("x") + time_mod.registry.reset("y") + + +def test_drift_report_empty_with_one_agent(): + from engine import time as time_mod + # reset everything to ensure isolation + for c in time_mod.registry.all(): + time_mod.registry.reset(c.agent_id) + time_mod.registry.get("only") + time_mod.registry.get("only").tau = 10.0 + report = time_mod.registry.drift_report() + assert report["pairs"] == [] + assert report["max_drift"] == 0.0 + time_mod.registry.reset("only") diff --git a/web/app.js b/web/app.js index 6799e51..87e4a84 100644 --- a/web/app.js +++ b/web/app.js @@ -99,6 +99,47 @@ function refreshAgentCards() { } } +function refreshClocks() { + if (!snapshot || !snapshot.clocks) return; + const wrap = document.getElementById('clocks'); + wrap.innerHTML = ''; + const entries = Object.entries(snapshot.clocks); + // sort by τ descending + entries.sort((a, b) => b[1].tau - a[1].tau); + const maxTau = Math.max(1, entries.length ? entries[0][1].tau : 1); + const colorFor = { anchor: '#ffd166', flora: '#6cf0c2', lovely: '#ff8fb1', spark: '#82aaff' }; + for (const [aid, c] of entries) { + const div = document.createElement('div'); + div.className = 'clock-card'; + const pct = (c.tau / maxTau) * 100; + div.innerHTML = ` +
${aid}
+
+
τ = ${c.tau.toFixed(2)} · pace = ${c.pace.toFixed(2)} op/s · ${c.n_ops} ops
+ `; + wrap.appendChild(div); + } +} + +function refreshDrift() { + if (!snapshot || !snapshot.drift) return; + const wrap = document.getElementById('drift'); + const d = snapshot.drift; + if (!d.pairs || d.pairs.length === 0) { + wrap.innerHTML = 'No multi-agent drift yet.'; + return; + } + const top = d.pairs[0]; + if (top.divergent) { + wrap.className = 'drift-warn'; + wrap.innerHTML = `⚠ DILATION DRIFT · ${top.a}↔${top.b}: |Δτ| = ${top.drift.toFixed(1)} (γ=${top.gamma_ab})
+ ${top.a} has experienced ${top.tau_a} units, ${top.b} ${top.tau_b}. Frame transformation exceeds threshold ${d.threshold}.`; + } else { + wrap.className = 'drift-ok'; + wrap.innerHTML = `✓ COHERENT · max drift = ${d.max_drift.toFixed(2)} (threshold ${d.threshold})`; + } +} + function refreshProposals() { const wrap = document.getElementById('proposals'); wrap.innerHTML = ''; @@ -158,8 +199,15 @@ async function refreshAll() { draw(); refreshHeader(); refreshAgentCards(); + refreshClocks(); + refreshDrift(); refreshProposals(); refreshConstitution(); + // LLM info + if (snapshot.llm) { + const info = document.getElementById('llmInfo'); + info.textContent = `${snapshot.llm.provider} · ${snapshot.llm.model}`; + } } function connectWS() { @@ -197,6 +245,10 @@ function connectWS() { } } else if (msg.type === 'tick') { document.getElementById('tick').textContent = msg.tick; + if (msg.clocks) snapshot.clocks = msg.clocks; + if (msg.drift) snapshot.drift = msg.drift; + refreshClocks(); + refreshDrift(); } }; } diff --git a/web/index.html b/web/index.html index c9d9d0a..df0e270 100644 --- a/web/index.html +++ b/web/index.html @@ -11,7 +11,8 @@
Tick 0 Agents 0 - Active Proposals 0 + Proposals 0 + connecting…
@@ -28,6 +29,10 @@