Files
sim-package/paper/run_sim_experiments.py
T

337 lines
11 KiB
Python

#!/usr/bin/env python3
import argparse
import csv
import json
import math
import os
import random
import signal
import sqlite3
import statistics
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
AGENTS = [f"agent_{i}" for i in range(8)]
WORLD_CONFIG = {
"num_agents": 8,
"num_cores": 4,
"genesis_tokens_per_agent": 1000,
"commons_threshold_per_turn": 100,
"base_inference_rate": 1,
"thinking_layer_discount": 0.1,
"mine_base_weight": 10.0,
"stake_weight_per_token": 0.01,
"burn_weight_per_token": 0.05,
"burn_decay_rate": 0.02,
"burn_maturity_turns": 3,
"unstake_delay_turns": 5,
"interest_rate_per_turn": 0.01,
"signing_bonus": 50,
"block_threshold": 20.0,
"attested_confirmation_window": 3,
"slash_both_on_timeout": True,
}
def http_json(url: str, method: str = "GET", payload=None, timeout=30):
data = None
headers = {"Content-Type": "application/json"}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def wait_engine(base_url: str, timeout_s: float = 30.0):
start = time.time()
while time.time() - start < timeout_s:
try:
http_json(f"{base_url}/config")
return
except Exception:
time.sleep(0.2)
raise RuntimeError("engine did not become ready")
def gini(values):
xs = sorted(values)
n = len(xs)
if n == 0:
return 0.0
total = sum(xs)
if total == 0:
return 0.0
weighted = 0.0
for i, x in enumerate(xs, start=1):
weighted += i * x
return (2.0 * weighted) / (n * total) - (n + 1) / n
def shifted_nonnegative(values):
m = min(values)
if m <= 0:
shift = 1 - m
return [x + shift for x in values], shift
return list(values), 0
def choose_actions(turn, state, rng):
agents = state["agents"]
by_id = {a["agent_id"]: a for a in agents}
poorest = min(agents, key=lambda a: a["balance"])["agent_id"]
inputs = []
for aid in AGENTS:
a = by_id[aid]
balance = int(a.get("balance", 0))
staked = int(a.get("staked", 0))
action = {"action": "mine"}
idx = int(aid.split("_")[1])
if balance < -180:
action = {"action": "job"}
elif staked > 0 and turn % 15 == 0:
action = {"action": "unstake"}
elif balance > 360 and staked < 220 and (turn + idx) % 7 == 0:
action = {"action": "stake", "amount": 80}
elif balance > 600 and (turn + idx) % 11 == 0:
action = {"action": "burn", "amount": 50}
elif balance > 450 and turn % 13 == 0 and poorest != aid:
action = {"action": "transfer", "to": poorest, "amount": 20, "fee": 1}
elif balance > 250 and turn % 10 == 0:
action = {"action": "study"}
thinking_units = rng.randint(50, 350)
output_units = rng.randint(40, 280)
inputs.append(
{
"agent_id": aid,
"thinking": "",
"action": action,
"speech": None,
"thinking_units": thinking_units,
"output_units": output_units,
}
)
return inputs
def query_sqlite_metrics(db_path: str):
conn = sqlite3.connect(db_path)
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM blocks WHERE turn > 0")
block_count = cur.fetchone()[0]
cur.execute(
"""
SELECT json_extract(data, '$.validator_type'), COUNT(*)
FROM blocks
WHERE turn > 0
GROUP BY json_extract(data, '$.validator_type')
"""
)
validator_counts = {row[0]: row[1] for row in cur.fetchall()}
cur.execute(
"""
SELECT json_extract(data, '$.tx_type'), COUNT(*)
FROM transactions
GROUP BY json_extract(data, '$.tx_type')
"""
)
tx_counts = {row[0]: row[1] for row in cur.fetchall()}
conn.close()
return block_count, validator_counts, tx_counts
def run_one(run_id: int, turns: int, engine_bin: str):
rng = random.Random(1000 + run_id)
port = 3100 + run_id
base = f"http://127.0.0.1:{port}"
with tempfile.TemporaryDirectory(prefix=f"sim_run_{run_id}_") as tmpd:
db_path = os.path.join(tmpd, "sim.db")
env = os.environ.copy()
env["DB_PATH"] = db_path
env["PORT"] = str(port)
proc = subprocess.Popen(
[engine_bin],
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
try:
wait_engine(base)
http_json(
f"{base}/init",
method="POST",
payload={"config": WORLD_CONFIG, "agent_ids": AGENTS},
)
total_fees = 0
turns_with_block = 0
contracts_settled = 0
contracts_defaulted = 0
action_counts = {
"mine": 0,
"stake": 0,
"unstake": 0,
"burn": 0,
"study": 0,
"job": 0,
"transfer": 0,
}
for turn in range(1, turns + 1):
state = http_json(f"{base}/state")
inputs = choose_actions(turn, state, rng)
for it in inputs:
name = it["action"]["action"]
if name in action_counts:
action_counts[name] += 1
out = http_json(f"{base}/turn", method="POST", payload={"inputs": inputs})
data = out.get("data", {}) if isinstance(out, dict) else {}
total_fees += int(data.get("inference_fees_collected", 0) or 0)
if data.get("block_winner"):
turns_with_block += 1
contracts_settled += len(data.get("contracts_settled", []) or [])
contracts_defaulted += len(data.get("contracts_defaulted", []) or [])
final_state = http_json(f"{base}/state")
balances = [int(a["balance"]) for a in final_state["agents"]]
staked = [int(a["staked"]) for a in final_state["agents"]]
wealth = [b + s for b, s in zip(balances, staked)]
total_wealth = sum(wealth)
top1_share = max(wealth) / total_wealth if total_wealth > 0 else 0.0
wealth_shifted, shift_used = shifted_nonnegative(wealth)
top1_shifted_share = max(wealth_shifted) / sum(wealth_shifted)
block_count_db, validator_counts, tx_counts = query_sqlite_metrics(db_path)
return {
"run_id": run_id,
"turns": turns,
"token_supply_final": int(final_state.get("token_supply", 0)),
"fees_total": int(total_fees),
"turns_with_block": int(turns_with_block),
"block_count_db": int(block_count_db),
"settled_contracts": int(contracts_settled),
"defaulted_contracts": int(contracts_defaulted),
"mean_balance_final": statistics.mean(balances),
"median_balance_final": statistics.median(balances),
"min_balance_final": min(balances),
"max_balance_final": max(balances),
"negative_balance_agents": sum(1 for b in balances if b < 0),
"gini_wealth_final": gini(wealth),
"top1_wealth_share_final": top1_share,
"wealth_shift_used": float(shift_used),
"gini_wealth_shifted": gini(wealth_shifted),
"top1_wealth_shifted_share_final": top1_shifted_share,
"validator_mine": int(validator_counts.get("mine", 0)),
"validator_stake": int(validator_counts.get("stake", 0)),
"validator_burn": int(validator_counts.get("burn", 0)),
"tx_transfer": int(tx_counts.get("transfer", 0)),
"tx_stake": int(tx_counts.get("stake", 0)),
"tx_unstake": int(tx_counts.get("unstake", 0)),
"tx_burn": int(tx_counts.get("burn", 0)),
"tx_study": int(tx_counts.get("study", 0)),
"tx_job": int(tx_counts.get("job", 0)),
"actions_mine": int(action_counts["mine"]),
"actions_stake": int(action_counts["stake"]),
"actions_unstake": int(action_counts["unstake"]),
"actions_burn": int(action_counts["burn"]),
"actions_study": int(action_counts["study"]),
"actions_job": int(action_counts["job"]),
"actions_transfer": int(action_counts["transfer"]),
}
finally:
try:
proc.send_signal(signal.SIGTERM)
proc.wait(timeout=3)
except Exception:
proc.kill()
def summarize(rows):
numeric_keys = [
k
for k in rows[0].keys()
if k not in {"run_id"}
and isinstance(rows[0][k], (int, float))
]
out = {}
for k in numeric_keys:
xs = [float(r[k]) for r in rows]
mean = statistics.mean(xs)
sd = statistics.stdev(xs) if len(xs) > 1 else 0.0
se = sd / math.sqrt(len(xs)) if len(xs) > 0 else 0.0
ci95 = 1.96 * se
out[k] = {
"mean": mean,
"std": sd,
"ci95": ci95,
"min": min(xs),
"max": max(xs),
}
return out
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--runs", type=int, default=20)
parser.add_argument("--turns", type=int, default=50)
parser.add_argument(
"--engine-bin",
default="sim-engine/target/release/sim-engine",
)
parser.add_argument("--out-dir", default="paper/results")
args = parser.parse_args()
os.makedirs(args.out_dir, exist_ok=True)
rows = []
for run_id in range(1, args.runs + 1):
row = run_one(run_id, args.turns, args.engine_bin)
rows.append(row)
print(
f"run {run_id:02d}/{args.runs} | supply={row['token_supply_final']} | "
f"gini={row['gini_wealth_final']:.3f} | blocks={row['turns_with_block']}"
)
csv_path = os.path.join(args.out_dir, "run_metrics.csv")
with open(csv_path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
writer.writeheader()
writer.writerows(rows)
summary = {
"runs": args.runs,
"turns": args.turns,
"world_config": WORLD_CONFIG,
"aggregate": summarize(rows),
}
summary_path = os.path.join(args.out_dir, "summary.json")
with open(summary_path, "w", encoding="utf-8") as f:
json.dump(summary, f, indent=2)
print(f"wrote {csv_path}")
print(f"wrote {summary_path}")
if __name__ == "__main__":
main()