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
This commit is contained in:
Jeuners 2026-06-15 02:27:11 +02:00
parent 8a52e3dfa3
commit 919866e50d
14 changed files with 882 additions and 129 deletions

24
.env.example Normal file
View file

@ -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

7
.gitignore vendored
View file

@ -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

View file

@ -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_<ID>_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.
---

View file

@ -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()

View file

@ -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_<ID>_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_<ID_UPPER>_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)
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,
}

View file

@ -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):

185
engine/time.py Normal file
View file

@ -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)

View file

@ -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

View file

@ -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(),
}

View file

@ -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": [
fake = {"message": {"tool_calls": [
{"function": {"name": "idle", "arguments": "{}"}}
]
}
}
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 == "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_<ID>_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)

163
tests/test_time.py Normal file
View file

@ -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")

View file

@ -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 = `
<div class="name" style="color:${colorFor[aid] || '#fff'}">${aid}</div>
<div class="tau-bar"><i style="width:${pct}%; background:${colorFor[aid] || '#fff'}"></i></div>
<div class="meta">τ = ${c.tau.toFixed(2)} · pace = ${c.pace.toFixed(2)} op/s · ${c.n_ops} ops</div>
`;
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 = '<small>No multi-agent drift yet.</small>';
return;
}
const top = d.pairs[0];
if (top.divergent) {
wrap.className = 'drift-warn';
wrap.innerHTML = `<b>⚠ DILATION DRIFT</b> · ${top.a}${top.b}: |Δτ| = ${top.drift.toFixed(1)} (γ=${top.gamma_ab})<br>
<small>${top.a} has experienced ${top.tau_a} units, ${top.b} ${top.tau_b}. Frame transformation exceeds threshold ${d.threshold}.</small>`;
} else {
wrap.className = 'drift-ok';
wrap.innerHTML = `<b>✓ COHERENT</b> · 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();
}
};
}

View file

@ -11,7 +11,8 @@
<div class="meta">
<span>Tick <b id="tick">0</b></span>
<span>Agents <b id="agentCount">0</b></span>
<span>Active Proposals <b id="propCount">0</b></span>
<span>Proposals <b id="propCount">0</b></span>
<span id="llmInfo" class="llm-info"></span>
<span class="ws-status" id="wsStatus">connecting…</span>
</div>
</header>
@ -28,6 +29,10 @@
</section>
<aside>
<h2>Time Dilation · Eigenzeit τ</h2>
<div id="clocks"></div>
<div id="drift"></div>
<h2>Live Feed</h2>
<ul id="feed"></ul>

View file

@ -8,6 +8,14 @@ header h1 { margin: 0; font-size: 18px; letter-spacing: 1px; color: #6cf0c2; }
.meta b { color: #d6e2ee; }
.ws-status.connected { color: #6cf0c2; }
.ws-status.disconnected { color: #ff6c6c; }
.llm-info { color: #82aaff; font-size: 11px; }
.clock-card { background: #0a1018; border: 1px solid #1c2733; padding: 6px 8px; margin: 4px 0; }
.clock-card .name { font-weight: bold; }
.clock-card .tau-bar { height: 4px; background: #1c2733; border-radius: 2px; margin: 4px 0; }
.clock-card .tau-bar > i { display: block; height: 100%; border-radius: 2px; }
.clock-card .meta { color: #6c8aa6; font-size: 10px; }
.drift-warn { background: #2a1018; border: 1px solid #ff6c6c; padding: 8px; margin: 6px 0; color: #ff8fb1; font-size: 11px; }
.drift-ok { background: #0a1018; border: 1px solid #1c2733; padding: 8px; margin: 6px 0; color: #6cf0c2; font-size: 11px; }
main { display: grid; grid-template-columns: 1fr 360px; gap: 16px; padding: 16px; }
.canvas-wrap { background: #0e141b; padding: 10px; border: 1px solid #1c2733; }
canvas { display: block; width: 100%; height: auto; image-rendering: pixelated;