sim/economy/sim.py: 1,000 operators choosing MINE, PROVE, HYBRID or OFF per card class with their own clients; sortition by weight with the 10-s window then open claiming, external jobs with the 90/10 split, backlog rule, difficulty clamps, GBM price. Scenarios a to f, 5 seeds: no backlog, no window miss, hash floor 0.74 of pre-event. Traffic sensitivity finds the shortage oscillation only above the proving fleet's capacity (100 to 300 shards per block); at 100 the sortition window (10 s to 20 s) is the lever that removes it. docs/analysis/economy-2026-10-04.md holds the model, assumptions, results, worst case and the proposal (window = p90 shard time plus a swap, 25 s at today's targets; B_p tied to the live fleet), not applied. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
745 lines
36 KiB
Python
745 lines
36 KiB
Python
#!/usr/bin/env python3
|
|
"""Igneum economy simulator: GPU operators choosing between mining and proving.
|
|
|
|
Agent-based model of N operators (default 1,000) with heterogeneous card fleets and electricity
|
|
prices. Every operator runs its own client and maximises its own profit: every ten minutes (own
|
|
phase) it compares, per card class, the expected profit of MINING (lottery share of the 80%
|
|
emission), PROVING (internal shards from the 20% pool by sortition then open claiming, plus
|
|
external jobs in dollars settled in IGN with a burn) and HYBRID (mine, prove only the shards it is
|
|
assigned, program swap each way), and switches when the gain beats its own hysteresis. Nothing is
|
|
scheduled centrally.
|
|
|
|
Time is in ticks of TICK seconds (180 s). Everything that happens inside a tick (the 10-s sortition
|
|
window, shard times of 6 to 20 s, open-claim races) is resolved with closed-form rates; everything
|
|
across ticks (hash, difficulty, backlog, weights, modes, price) is state. Blocks, shards, jobs and
|
|
block attribution are sampled; shard allocation across operators is fluid (expected values).
|
|
|
|
All figures are approximate unless the source says measured (RTX 5090 229 MH/s is measured,
|
|
docs/bench-log.md). See docs/analysis/economy-2026-10-04.md for the assumptions table.
|
|
|
|
Usage
|
|
python3 sim.py six scenarios, 5 seeds, markdown to stdout
|
|
python3 sim.py --scenarios b --seeds 1 one run
|
|
python3 sim.py --levers b --seeds 3 lever study on scenario b
|
|
python3 sim.py --set pool=0.3,window=20 override parameters
|
|
python3 sim.py --dump b --seeds 1 hourly trajectory CSV for scenario b
|
|
"""
|
|
import argparse
|
|
import math
|
|
import sys
|
|
import time
|
|
|
|
import numpy as np
|
|
|
|
# ---------------------------------------------------------------- parameters (all approximate)
|
|
P = dict(
|
|
tick=180.0, # s per tick
|
|
days=30,
|
|
n_ops=1000,
|
|
emission=31.688, # IGN per block, pre-halving, ramp complete (spec 2.5)
|
|
pool=0.20, # proving pool share of emission (spec 2.5, 5.3)
|
|
window=10.0, # sortition exclusive window, s (spec 7.2)
|
|
assignees=8, # provers drawn per shard (spec 7.2)
|
|
burn=0.10, # external job burn (spec 5.4)
|
|
timeout=300.0, # external job claim timeout, s (O-5.6, open; design 6 says 3,600 blocks to expire)
|
|
shards_per_block=3.0, # internal demand, shards (3060-20-s units) per block at launch traffic
|
|
job_work=10.0, # shard-equivalents of work per external job
|
|
ext_usd_day=2000.0, # external demand, dollars per day (customer brief: market low millions/yr)
|
|
ext_mult=1.0, # scenario multiplier on the dollar price per job
|
|
price0=0.012, # $ per IGN at t=0
|
|
vol_day=0.05, # price daily volatility
|
|
swap=5.0, # program swap, s each way (hybrid)
|
|
aggregation=4.0, # aggregation plus inclusion, s, added to every block proof
|
|
waste=0.5, # wasted duplicate attempts per open-claim shard (race losers)
|
|
hyst_lo=0.05, hyst_hi=0.25,
|
|
dwell_ticks=20, # minimum ticks between switches of one (operator, class): 1 h
|
|
decide_every=4, # ticks between decisions: 12 min
|
|
ema_ticks=20.0, # observation window for realised rates: 1 h
|
|
farm_share=0.20, # operator 0 share of total hash
|
|
backlog_rule=600.0, # s of oldest age per halving of B_p (design 4.3)
|
|
)
|
|
|
|
# card classes: 5090 (measured hash), 3090, 3060, small (approximate)
|
|
CLS = ["5090", "3090", "3060", "small"]
|
|
HASH = np.array([229.0, 57.0, 46.0, 25.0]) # MH/s
|
|
PMINE = np.array([450.0, 320.0, 170.0, 120.0]) # W while hashing
|
|
PPROVE = np.array([500.0, 350.0, 170.0, 0.0]) # W while proving
|
|
PIDLE = np.array([60.0, 50.0, 30.0, 0.0]) # W proving-ready, idle
|
|
TPROVE = np.array([6.0, 12.0, 20.0, 1e9]) # s per shard (3060 = phase 2 gate target); small cannot prove
|
|
CANHYB = np.array([True, True, False, False]) # hybrid needs the dataset and the prover resident: 24 GB cards only
|
|
MIX = np.array([0.25, 0.25, 0.30, 0.20]) # fleet mix by card count
|
|
CANPROVE = np.array([True, True, True, False])
|
|
OFF, MINE, PROVE, HYB = 0, 1, 2, 3
|
|
|
|
|
|
def build_population(rng, p, extra=0):
|
|
n = p["n_ops"] + extra
|
|
size = np.maximum(1, np.round(rng.lognormal(math.log(5.0), 1.2, n))).astype(int)
|
|
cards = np.zeros((n, 4), dtype=float)
|
|
for i in range(n):
|
|
cards[i] = rng.multinomial(size[i], MIX)
|
|
# electricity: farms cheaper; lognormal around $0.10/kWh
|
|
z = (np.log(size) - np.log(5.0)) / 1.2
|
|
elec = np.exp(rng.normal(math.log(0.10) - 0.25 * z, 0.40, n))
|
|
elec = np.clip(elec, 0.02, 0.40)
|
|
# operator 0: the farm, all 5090s, sized to farm_share of total hash, cheap power
|
|
cards[0] = 0
|
|
rest = (cards[1:p["n_ops"]] * HASH).sum()
|
|
farm_hash = rest * p["farm_share"] / (1 - p["farm_share"])
|
|
cards[0, 0] = max(1, round(farm_hash / HASH[0]))
|
|
elec[0] = 0.05
|
|
hyst = rng.uniform(p["hyst_lo"], p["hyst_hi"], n)
|
|
phase = rng.integers(0, p["decide_every"], n)
|
|
return cards, elec, hyst, phase
|
|
|
|
|
|
class Sim:
|
|
def __init__(self, scenario, seed, p):
|
|
self.p = dict(p)
|
|
self.sc = scenario
|
|
self.rng = np.random.default_rng(seed)
|
|
p = self.p
|
|
extra = 0
|
|
if scenario == "f":
|
|
extra = 200
|
|
if scenario == "e":
|
|
p["farm_share"] = 0.30
|
|
self.cards, self.elec, self.hyst, self.phase = build_population(self.rng, p, extra)
|
|
self.n = self.cards.shape[0]
|
|
self.n_base = p["n_ops"]
|
|
self.active = np.ones(self.n, bool)
|
|
if extra:
|
|
self.active[self.n_base:] = False
|
|
# the incoming pool: 200 operators sized so their hash equals the base hash
|
|
base_hash = (self.cards[: self.n_base] * HASH).sum()
|
|
pool_hash = (self.cards[self.n_base:] * HASH).sum()
|
|
self.cards[self.n_base:] *= base_hash / pool_hash
|
|
self.cards[self.n_base:] = np.round(self.cards[self.n_base:])
|
|
self.elec[self.n_base:] = np.clip(self.elec[self.n_base:], 0.02, 0.08)
|
|
self.mode = np.full((self.n, 4), MINE, dtype=int)
|
|
self.mode[self.cards == 0] = OFF
|
|
# initial allocation: cards with cheap power start a fifth of their provable cards proving
|
|
for i in range(self.n):
|
|
for c in range(3):
|
|
if self.cards[i, c] > 0 and self.rng.random() < 0.20:
|
|
self.mode[i, c] = PROVE
|
|
if not self.active.all():
|
|
self.mode[~self.active] = OFF
|
|
self.fixed_mine = np.zeros(self.n, bool)
|
|
self.withhold = np.zeros(self.n, bool)
|
|
if scenario == "e":
|
|
self.fixed_mine[0] = True
|
|
self.withhold[0] = True
|
|
self.mode[0] = np.where(self.cards[0] > 0, MINE, OFF)
|
|
self.dead = np.zeros(self.n, bool)
|
|
# weight: 30-day ring of mined blocks per operator, seeded from hash shares
|
|
self.hist = np.zeros((self.n, 30))
|
|
h0 = (self.cards * HASH * (self.mode != OFF)).sum(1)
|
|
h0[~self.active] = 0
|
|
self.hist[:] = (h0 / h0.sum() * 86400.0)[:, None]
|
|
self.w = self.hist.sum(1)
|
|
self.cards_active = self.cards[self.active].sum()
|
|
self.day = 0
|
|
self.last_switch = np.full((self.n, 4), -10**9, dtype=int)
|
|
self.price = p["price0"]
|
|
self.H_est = h0.sum() * 1e6
|
|
self.D = self.H_est * 1.0 # expected hashes per block, 1 block/s
|
|
self.q = 0.0 # backlog, shards
|
|
self.age = 0.0 # oldest unproven block age, s
|
|
self.duty = np.zeros((self.n, 4)) # hybrid proving duty
|
|
# observed rates (EMA)
|
|
self.R_hash = p["emission"] * (1 - p["pool"]) / self.H_est # IGN per hash per s
|
|
self.open_pc = np.zeros(4) # $ per hour per PROVE card by class, open claims
|
|
self.asg_pw = 0.0 # $ per hour per unit weight, assigned shards
|
|
self.asg_pw_h = 0.0 # same for hybrid responders
|
|
self.asg_ext_pw = 0.0 # $ per hour per unit weight, assigned external jobs
|
|
self.t_open = p["window"] + TPROVE[0]
|
|
self.eligible = CANPROVE.copy()
|
|
self.ok60 = 1.0
|
|
self.ok20 = 1.0
|
|
self.hourly = []
|
|
self.hour_acc = []
|
|
self.cls_profit = np.zeros(4)
|
|
self.cls_cards = np.zeros(4)
|
|
self.cls_shards = np.zeros(4)
|
|
self.ext_served = 0.0
|
|
self.ext_demand = 0.0
|
|
self.burned_ign = 0.0
|
|
self.switches = 0
|
|
|
|
# ------------------------------------------------------------ one tick
|
|
def step(self, t):
|
|
p = self.p
|
|
T = p["tick"]
|
|
day_f = t * T / 86400.0
|
|
rng = self.rng
|
|
# scenario events
|
|
if self.sc == "d" and day_f >= 10 and not self.dead[0]:
|
|
self.dead[0] = True
|
|
self.mode[0] = OFF
|
|
if self.sc == "f" and day_f >= 10 and not self.active[self.n_base]:
|
|
self.active[self.n_base:] = True
|
|
self.mode[self.n_base:] = np.where(self.cards[self.n_base:] > 0, MINE, OFF)
|
|
self.cards_active = self.cards[self.active].sum()
|
|
ext_mult = p["ext_mult"]
|
|
if self.sc == "b" and day_f >= 7:
|
|
ext_mult = 10.0
|
|
if self.sc == "c":
|
|
ext_mult = 0.0
|
|
# price
|
|
dt = T / 86400.0
|
|
self.price *= math.exp(p["vol_day"] * math.sqrt(dt) * rng.standard_normal() - 0.5 * p["vol_day"] ** 2 * dt)
|
|
if self.sc == "b" and 7 <= day_f < 14:
|
|
self.price *= math.exp(math.log(0.30) / (7 * 86400.0 / T))
|
|
price = self.price
|
|
|
|
cards, mode = self.cards, self.mode
|
|
is_m = mode == MINE
|
|
is_p = mode == PROVE
|
|
is_h = mode == HYB
|
|
# hash
|
|
hcards = cards * HASH * 1e6
|
|
h_op = (hcards * (is_m + is_h * (1 - self.duty))).sum(1)
|
|
H = h_op.sum()
|
|
if H <= 0:
|
|
H = 1.0
|
|
# difficulty controller (dual-lane response approximated: 120-block estimate, 3%/10% clamps)
|
|
lam = T * H / self.D
|
|
blocks = rng.poisson(lam)
|
|
self.H_est += (H - self.H_est) * min(1.0, blocks / 120.0)
|
|
ratio = self.H_est / self.D
|
|
ratio = min(max(ratio, 0.90 ** blocks), 1.03 ** blocks)
|
|
self.D *= ratio
|
|
E = p["emission"]
|
|
# mined blocks per operator
|
|
mined = rng.multinomial(blocks, h_op / H) if blocks > 0 else np.zeros(self.n, int)
|
|
self.hist[:, self.day % 30] += mined
|
|
self.w += mined
|
|
w = self.w # a dead operator's weight stays in the window until it ages out
|
|
W = w.sum()
|
|
mine_ign = mined * E * (1 - p["pool"])
|
|
pool_ign = blocks * E * p["pool"]
|
|
|
|
# internal shard demand with the backlog rule
|
|
spb = p["shards_per_block"] * 0.5 ** math.floor(self.age / p["backlog_rule"])
|
|
shards = rng.poisson(blocks * spb) if blocks > 0 else 0
|
|
pool_per_shard = pool_ign / shards if shards > 0 else 0.0
|
|
|
|
# capacities in shard-equivalents per tick
|
|
capP = np.where(is_p, cards * T / TPROVE, 0.0)
|
|
capP[:, 3] = 0
|
|
capH = np.where(is_h, cards * T / (TPROVE + 2 * p["swap"]), 0.0)
|
|
capH[:, 2:] = 0
|
|
capP[self.dead | ~self.active] = 0
|
|
capH[self.dead | ~self.active] = 0
|
|
capP_op = capP.sum(1)
|
|
capH_op = capH.sum(1)
|
|
resp = ((capP_op + capH_op) >= 1.0) & ~self.withhold & ~self.dead
|
|
a = (w * resp).sum() / W if W > 0 else 0.0
|
|
p_resp = 1 - (1 - a) ** p["assignees"]
|
|
shards_served = np.zeros((self.n, 4))
|
|
# external jobs
|
|
jobs_usd = p["ext_usd_day"] * ext_mult
|
|
job_price = (p["ext_usd_day"] / (p["ext_usd_day"] / 50.0)) * ext_mult # $50 per job baseline
|
|
jobs = rng.poisson(p["ext_usd_day"] / 50.0 * T / 86400.0) if jobs_usd > 0 else 0
|
|
ext_work = jobs * p["job_work"]
|
|
ext_pay_per_shard = job_price / p["job_work"] * (1 - p["burn"])
|
|
eligible = (p["job_work"] * TPROVE <= p["timeout"]) & CANPROVE
|
|
ext_served = np.zeros((self.n, 4))
|
|
ext_asg = [0.0]
|
|
ext_open = np.zeros(4)
|
|
self.ext_demand += ext_work
|
|
internal_first = pool_per_shard * price >= ext_pay_per_shard
|
|
|
|
def serve_external():
|
|
# jobs are assigned by the same sortition as shards (design 6), claimed with a bond, so the
|
|
# assignee keeps the job: the assigned part goes by weight, the rest is open and the fastest
|
|
# eligible proving card claims it; what nobody can take before the deadline expires.
|
|
nonlocal ext_work
|
|
if ext_work <= 0:
|
|
return
|
|
capE_P = capP * eligible
|
|
capE_H = capH * eligible
|
|
capE_op = capE_P.sum(1) + capE_H.sum(1)
|
|
respE = (capE_op >= 1.0) & ~self.withhold & ~self.dead
|
|
aE = (w * respE).sum() / W if W > 0 else 0.0
|
|
pE = 1 - (1 - aE) ** p["assignees"]
|
|
s_asg = ext_work * pE
|
|
over = 0.0
|
|
wrE = w * respE
|
|
WrE = wrE.sum()
|
|
if s_asg > 0 and WrE > 0:
|
|
x = s_asg * wrE / WrE
|
|
got = np.minimum(x, capE_op)
|
|
over = s_asg - got.sum()
|
|
capE = capE_P + capE_H
|
|
frac = capE / np.maximum(capE.sum(1, keepdims=True), 1e-9)
|
|
alloc = got[:, None] * frac
|
|
ext_served[:] += alloc
|
|
ext_asg[0] += got.sum()
|
|
usedP = alloc * (capE_P / np.maximum(capE, 1e-9))
|
|
capP[:] -= usedP
|
|
capH[:] -= alloc - usedP
|
|
else:
|
|
over = s_asg
|
|
open_w = ext_work - s_asg + over
|
|
for lat, cap, c in self.open_order(capP, capH):
|
|
if not eligible[c] or open_w <= 0:
|
|
continue
|
|
tot = cap[:, c].sum()
|
|
if tot <= 0:
|
|
continue
|
|
take = min(open_w, tot / (1 + p["waste"]))
|
|
alloc = cap[:, c] * (take / tot)
|
|
ext_served[:, c] += alloc
|
|
ext_open[c] += take
|
|
cap[:, c] -= alloc * (1 + p["waste"])
|
|
open_w -= take
|
|
ext_work = open_w
|
|
|
|
if not internal_first:
|
|
serve_external()
|
|
# assignee race: class mix of responders
|
|
wr = w * resp
|
|
Wr = wr.sum()
|
|
if Wr > 0:
|
|
capPH = capP + capH
|
|
share = capPH / np.maximum(capPH.sum(1, keepdims=True), 1e-9)
|
|
mixP = (wr[:, None] * (capP / np.maximum(capPH.sum(1, keepdims=True), 1e-9))).sum(0) / Wr
|
|
mixH = (wr[:, None] * (capH / np.maximum(capPH.sum(1, keepdims=True), 1e-9))).sum(0) / Wr
|
|
else:
|
|
mixP = np.zeros(4)
|
|
mixH = np.zeros(4)
|
|
t_open = p["window"] + np.inf
|
|
for lat, cap, c in self.open_order(capP, capH):
|
|
if cap[:, c].sum() > 0:
|
|
t_open = p["window"] + lat
|
|
break
|
|
LA_P = TPROVE
|
|
LA_H = TPROVE + p["swap"]
|
|
winP = (LA_P <= t_open) * mixP
|
|
winH = (LA_H <= t_open) * mixH
|
|
p_win = winP[:3].sum() + winH[:3].sum() # given p_resp
|
|
s_asg = shards * p_resp * p_win
|
|
# allocate assigned shards by weight, cap by capacity
|
|
if s_asg > 0 and Wr > 0:
|
|
x = s_asg * wr / Wr
|
|
capPH_op = capP.sum(1) + capH.sum(1)
|
|
got = np.minimum(x, capPH_op)
|
|
overflow = s_asg - got.sum()
|
|
# split within operator across classes by capacity
|
|
capPH = capP + capH
|
|
frac = capPH / np.maximum(capPH.sum(1, keepdims=True), 1e-9)
|
|
alloc = got[:, None] * frac
|
|
shards_served += alloc
|
|
usedP = alloc * (capP / np.maximum(capPH, 1e-9))
|
|
usedH = alloc - usedP
|
|
capP -= usedP
|
|
capH -= usedH
|
|
else:
|
|
overflow = s_asg
|
|
open_new = shards - s_asg + overflow
|
|
q_prev = self.q
|
|
open_total = open_new + q_prev
|
|
served_open = 0.0
|
|
open_by_cls = np.zeros(4)
|
|
open_lat = np.zeros(4)
|
|
for lat, cap, c in self.open_order(capP, capH):
|
|
tot = cap[:, c].sum()
|
|
if tot <= 0 or open_total - served_open <= 0:
|
|
continue
|
|
take = min(open_total - served_open, tot / (1 + p["waste"]))
|
|
alloc = cap[:, c] * (take / tot)
|
|
shards_served[:, c] += alloc
|
|
open_by_cls[c] += take
|
|
open_lat[c] += take * lat
|
|
cap[:, c] -= alloc * (1 + p["waste"])
|
|
served_open += take
|
|
self.q = max(0.0, open_total - served_open)
|
|
if internal_first:
|
|
serve_external()
|
|
self.ext_served += ext_served.sum()
|
|
# backlog age and proof latency
|
|
arr_rate = max(shards / T, 1e-9)
|
|
if self.q <= 0:
|
|
self.age = 0.0
|
|
elif served_open >= q_prev:
|
|
# the old queue drained this tick; what is left arrived this tick
|
|
self.age = min(T, self.q / arr_rate) + p["window"] + TPROVE[2]
|
|
else:
|
|
self.age += T
|
|
wait = q_prev / max(served_open / T, 1e-9) if q_prev > 0 else 0.0
|
|
thr60 = 60.0 - p["aggregation"]
|
|
thr20 = 20.0 - p["aggregation"]
|
|
p_q = min(1.0, max(0.0, (open_new - max(0.0, served_open - q_prev)) / max(shards, 1e-9))) if shards > 0 else 0.0
|
|
# the assignee branch only counts where it wins; losers were served open
|
|
pa = p_resp
|
|
p_assigned_won = pa * p_win
|
|
okA60 = pa * (((LA_P <= t_open) & (LA_P <= thr60)) * mixP)[:3].sum() + pa * (((LA_H <= t_open) & (LA_H <= thr60)) * mixH)[:3].sum()
|
|
okA20 = pa * (((LA_P <= t_open) & (LA_P <= thr20)) * mixP)[:3].sum() + pa * (((LA_H <= t_open) & (LA_H <= thr20)) * mixH)[:3].sum()
|
|
if open_by_cls[:3].sum() > 0:
|
|
# open-served shards: latency = window + wait + class time, class by share served
|
|
l_open = p["window"] + wait + open_lat[:3] / np.maximum(open_by_cls[:3], 1e-9)
|
|
sh = open_by_cls[:3] / open_by_cls[:3].sum()
|
|
okO60 = (sh * (l_open <= thr60)).sum()
|
|
okO20 = (sh * (l_open <= thr20)).sum()
|
|
else:
|
|
okO60 = okO20 = 0.0
|
|
p_open_served = max(0.0, 1 - p_assigned_won - p_q)
|
|
ps60 = min(1.0, okA60 + p_open_served * okO60)
|
|
ps20 = min(1.0, okA20 + p_open_served * okO20)
|
|
lam_s = max(spb, 1e-9)
|
|
def block_ok(ps):
|
|
return (math.exp(-lam_s * (1 - ps)) - math.exp(-lam_s)) / (1 - math.exp(-lam_s))
|
|
self.ok60 = block_ok(ps60)
|
|
self.ok20 = block_ok(ps20)
|
|
# income and power
|
|
prove_ign = shards_served * pool_per_shard
|
|
ext_usd = ext_served * ext_pay_per_shard
|
|
self.burned_ign += ext_served.sum() * (job_price / p["job_work"]) * p["burn"] / max(price, 1e-9)
|
|
busyP = np.where(is_p, np.minimum(1.0, (shards_served + ext_served) * TPROVE / np.maximum(cards * T, 1e-9)), 0.0)
|
|
busyP[:, 3] = 0
|
|
dutyH = np.where(is_h, np.minimum(1.0, (shards_served + ext_served) * (TPROVE + 2 * p["swap"]) / np.maximum(cards * T, 1e-9)), 0.0)
|
|
dutyH[:, 3] = 0
|
|
self.duty = dutyH
|
|
kwh = cards * T / 3600.0 / 1000.0
|
|
power_w = is_m * PMINE + is_p * (busyP * PPROVE + (1 - busyP) * PIDLE) + is_h * ((1 - dutyH) * PMINE + dutyH * PPROVE)
|
|
cost = kwh * power_w * self.elec[:, None]
|
|
# mining income per class within operator by hash share
|
|
hsh = hcards * (is_m + is_h * (1 - self.duty))
|
|
hfrac = hsh / np.maximum(hsh.sum(1, keepdims=True), 1e-9)
|
|
mine_usd = (mine_ign * price)[:, None] * hfrac
|
|
inc = mine_usd + prove_ign * price + ext_usd
|
|
profit = inc - cost
|
|
# observed rates
|
|
al = 1.0 / p["ema_ticks"]
|
|
self.R_hash += ((blocks * E * (1 - p["pool"]) / H / T) - self.R_hash) * al
|
|
nPH_cls = (np.where(is_p | is_h, cards, 0.0)).sum(0)
|
|
open_inc = open_by_cls * pool_per_shard * price + ext_open * ext_pay_per_shard # $ by class from open claims
|
|
open_pc = np.where(nPH_cls > 0, open_inc / np.maximum(nPH_cls, 1e-9), 0.0) * 3600.0 / T
|
|
self.open_pc += (open_pc - self.open_pc) * al
|
|
asg_usd = (s_asg - overflow) * pool_per_shard * price
|
|
asg_pw = asg_usd / max(Wr, 1e-9) * 3600.0 / T
|
|
self.asg_pw += (asg_pw - self.asg_pw) * al
|
|
asg_ext_pw = ext_asg[0] * ext_pay_per_shard / max(W, 1e-9) * 3600.0 / T
|
|
self.asg_ext_pw += (asg_ext_pw - self.asg_ext_pw) * al
|
|
self.t_open = t_open
|
|
self.eligible = eligible
|
|
# decisions
|
|
self.decide(t, price, w, Wr, p_resp, p_win, t_open)
|
|
# accumulate hourly
|
|
ca = max(self.cards_active, 1)
|
|
n_p = (cards * is_p).sum()
|
|
n_h = (cards * is_h).sum()
|
|
n_m = (cards * is_m).sum()
|
|
self.hour_acc.append((
|
|
H / 1e6, (hcards * (mode != OFF)).sum() / 1e6, n_p / ca,
|
|
n_h / ca, 1 - (n_p + n_h + n_m) / ca,
|
|
blocks / T, self.D, self.q, self.age, self.ok60, self.ok20, price,
|
|
ext_served.sum(), ext_work, shards,
|
|
))
|
|
self.cls_profit += profit.sum(0)
|
|
self.cls_cards += (cards * self.active[:, None]).sum(0)
|
|
self.cls_shards += shards_served.sum(0)
|
|
if len(self.hour_acc) * T >= 3600:
|
|
arr = np.array(self.hour_acc)
|
|
self.hourly.append(arr.mean(0).tolist() + [arr[:, 8].max(), arr[:, 0].min()])
|
|
self.hour_acc = []
|
|
if (t + 1) * T % 86400 == 0:
|
|
self.day += 1
|
|
self.w -= self.hist[:, self.day % 30]
|
|
self.hist[:, self.day % 30] = 0
|
|
|
|
def open_order(self, capP, capH):
|
|
"""Open-claim service order: the fastest responder wins the race. PROVE cards at their shard
|
|
time, HYBRID cards at shard time plus one program swap."""
|
|
p = self.p
|
|
order = [(TPROVE[c], capP, c) for c in range(3)] + [(TPROVE[c] + p["swap"], capH, c) for c in range(2)]
|
|
order.sort(key=lambda x: x[0])
|
|
return order
|
|
|
|
# ------------------------------------------------------------ decisions
|
|
def decide(self, t, price, w, Wr, p_resp, p_win, t_open):
|
|
p = self.p
|
|
due = ((t + self.phase) % p["decide_every"] == 0) & self.active & ~self.dead & ~self.fixed_mine
|
|
if not due.any():
|
|
return
|
|
idx = np.nonzero(due)[0]
|
|
cards = self.cards[idx]
|
|
elec = self.elec[idx][:, None]
|
|
n_pcards = (self.cards[idx] * ((self.mode[idx] == PROVE) | (self.mode[idx] == HYB))).sum(1, keepdims=True)
|
|
# $ per card-hour by class
|
|
v_mine = HASH * 1e6 * self.R_hash * price * 3600.0 - PMINE * elec / 1000.0
|
|
per_w = w[idx][:, None] / np.maximum(n_pcards + (n_pcards == 0) * cards, 1e-9)
|
|
asg_int = self.asg_pw * per_w # assigned internal shards, $ per card-hour at this operator's weight
|
|
asg_ext = self.asg_ext_pw * per_w * self.eligible
|
|
# a prover with no weight gets only open claims; one with weight gets assigned work shared over its proving cards
|
|
v_prove = self.open_pc + asg_ext + asg_int * (TPROVE <= t_open) - PIDLE * elec / 1000.0 - 0.3 * (PPROVE - PIDLE) * elec / 1000.0
|
|
v_hyb = v_mine * (1 - np.minimum(0.5, self.duty[idx])) + 0.9 * (asg_ext + asg_int * ((TPROVE + p["swap"]) <= t_open))
|
|
v_prove[:, 3] = -1e9
|
|
v_hyb[:, 2:] = -1e9
|
|
v_off = np.zeros_like(v_mine)
|
|
V = np.stack([v_off, v_mine, v_prove, v_hyb], 0) # (4 modes, ops, classes)
|
|
cur = self.mode[idx]
|
|
v_cur = np.take_along_axis(V, cur[None], 0)[0]
|
|
best = V.argmax(0)
|
|
v_best = V.max(0)
|
|
hy = self.hyst[idx][:, None]
|
|
gain = v_best - v_cur
|
|
ok = (gain > hy * np.abs(v_cur) + 1e-4) & (cards > 0) & ((t - self.last_switch[idx]) >= p["dwell_ticks"])
|
|
ok &= best != cur
|
|
if ok.any():
|
|
newmode = np.where(ok, best, cur)
|
|
self.mode[idx] = newmode
|
|
ls = self.last_switch[idx]
|
|
ls[ok] = t
|
|
self.last_switch[idx] = ls
|
|
self.switches += int(ok.sum())
|
|
|
|
def run(self):
|
|
nt = int(self.p["days"] * 86400 / self.p["tick"])
|
|
for t in range(nt):
|
|
self.step(t)
|
|
return np.array(self.hourly)
|
|
|
|
|
|
COLS = ["hash_MHs", "hash_pot", "frac_prove", "frac_hyb", "frac_off", "bps", "D", "q", "age", "ok60", "ok20",
|
|
"price", "ext_served", "ext_lost", "shards", "age_max", "hash_min"]
|
|
|
|
|
|
def metrics(hourly, sim):
|
|
h = hourly
|
|
d = {}
|
|
hrs = h.shape[0]
|
|
nd = hrs // 24
|
|
def day_mean(col, d0, d1):
|
|
d0, d1 = min(d0, nd - 1), min(d1, nd)
|
|
return h[d0 * 24:d1 * 24, COLS.index(col)].mean()
|
|
def day_max(col, dd):
|
|
dd = min(dd, nd)
|
|
return h[(dd - 1) * 24:dd * 24, COLS.index(col)].max()
|
|
pre_hash = day_mean("hash_MHs", 1, 7) if nd >= 7 else h[:, COLS.index("hash_MHs")].mean()
|
|
d["hash_pre"] = pre_hash
|
|
d["hash_min_ratio"] = h[24:, COLS.index("hash_min")].min() / pre_hash if nd >= 2 else 1.0
|
|
d["hash_d30_ratio"] = day_mean("hash_MHs", 29, 30) / pre_hash
|
|
# hours below 50% of pre-event hash
|
|
d["hours_hash_lt50"] = int((h[24:, COLS.index("hash_MHs")] < 0.5 * pre_hash).sum())
|
|
d["mining_hash_share"] = (h[:, COLS.index("hash_MHs")] / np.maximum(h[:, COLS.index("hash_pot")], 1e-9)).mean()
|
|
for dd in (1, 7, 10, 15, 20, 30):
|
|
d[f"prove_d{dd}"] = day_mean("frac_prove", dd - 1, dd)
|
|
d[f"hyb_d{dd}"] = day_mean("frac_hyb", dd - 1, dd)
|
|
d[f"off_d{dd}"] = day_mean("frac_off", dd - 1, dd)
|
|
d["age_max"] = h[:, COLS.index("age_max")].max()
|
|
d["q_max"] = h[:, COLS.index("q")].max()
|
|
d["age_d10"] = day_max("age_max", 10)
|
|
d["age_d20"] = day_max("age_max", 20)
|
|
d["age_d30"] = day_max("age_max", 30)
|
|
daily_age = h[:, COLS.index("age_max")].reshape(-1, 24).max(1)
|
|
x = np.arange(10)
|
|
slope = np.polyfit(x, daily_age[-10:], 1)[0] if nd >= 10 else 0.0
|
|
d["backlog_growing"] = bool(slope > 0 and daily_age[-1] > 60)
|
|
d["backlog_600"] = bool(d["age_max"] > 600)
|
|
d["ok60_mean"] = h[:, COLS.index("ok60")].mean()
|
|
d["ok60_min_day"] = h[:, COLS.index("ok60")].reshape(-1, 24).mean(1).min()
|
|
d["ok20_mean"] = h[:, COLS.index("ok20")].mean()
|
|
d["bps_mean"] = h[:, COLS.index("bps")].mean()
|
|
d["bps_max"] = h[:, COLS.index("bps")].max()
|
|
d["bps_min"] = h[:, COLS.index("bps")].min()
|
|
d["D_end_over_start"] = h[-1, COLS.index("D")] / h[min(24, hrs - 1), COLS.index("D")]
|
|
d["price_end"] = h[-1, COLS.index("price")]
|
|
es, el = h[:, COLS.index("ext_served")].sum(), h[:, COLS.index("ext_lost")].sum()
|
|
d["ext_delivered"] = es / (es + el) if es + el > 0 else float("nan")
|
|
# oscillation: zero crossings of the proving fraction around its 24-h mean, per day, last 10 days
|
|
fp = h[:, COLS.index("frac_prove")]
|
|
ma = np.convolve(fp, np.ones(24) / 24, mode="same")
|
|
dev = (fp - ma)[-240:]
|
|
d["osc_cross_per_day"] = float((np.diff(np.sign(dev)) != 0).sum() / 10.0)
|
|
d["osc_amp_pts"] = float((np.percentile(fp[-240:], 90) - np.percentile(fp[-240:], 10)) * 100)
|
|
d["osc_std_pts"] = float(fp[-240:].std() * 100)
|
|
days = sim.p["days"]
|
|
d["profit_card_day"] = sim.cls_profit / np.maximum(sim.cls_cards / (days * 86400 / sim.p["tick"]), 1e-9) / days
|
|
d["shard_share"] = sim.cls_shards / max(sim.cls_shards.sum(), 1e-9)
|
|
d["switches"] = sim.switches
|
|
d["miner_shortage"] = bool(d["hours_hash_lt50"] >= 1)
|
|
d["window_miss"] = bool(d["ok60_min_day"] < 0.90)
|
|
d["oscillation"] = bool(d["osc_amp_pts"] > 10 and d["osc_cross_per_day"] >= 1)
|
|
return d
|
|
|
|
|
|
SCEN = {
|
|
"a": "baseline",
|
|
"b": "external pays 10x from day 7, coin price falls 70% over days 7 to 14",
|
|
"c": "no external demand",
|
|
"d": "the 20% operator disappears at day 10",
|
|
"e": "a 30% operator never fulfils its assignments",
|
|
"f": "a pool with hash equal to the network's arrives at day 10 (2x hash)",
|
|
}
|
|
|
|
|
|
def fmt(v, nd=2):
|
|
if isinstance(v, (bool, np.bool_)):
|
|
return "yes" if v else "no"
|
|
if isinstance(v, (int, np.integer)):
|
|
return f"{v:,}"
|
|
if isinstance(v, float):
|
|
if abs(v) >= 1000:
|
|
return f"{v:,.0f}"
|
|
return f"{v:.{nd}f}"
|
|
return str(v)
|
|
|
|
|
|
def agg(ms, key):
|
|
vals = np.array([m[key] for m in ms], dtype=float)
|
|
return vals.mean(), vals.min(), vals.max()
|
|
|
|
|
|
def run_scenarios(scen, seeds, p, quiet=False):
|
|
out = {}
|
|
for s in scen:
|
|
ms = []
|
|
for seed in seeds:
|
|
t0 = time.time()
|
|
sim = Sim(s, seed, p)
|
|
h = sim.run()
|
|
ms.append(metrics(h, sim))
|
|
if not quiet:
|
|
print(f"<!-- scenario {s} seed {seed}: {time.time() - t0:.1f} s -->", file=sys.stderr)
|
|
out[s] = ms
|
|
return out
|
|
|
|
|
|
def table_scenarios(out):
|
|
keys = [
|
|
("mining_hash_share", "hash mining share (mean)"),
|
|
("prove_d1", "cards proving, day 1"), ("prove_d10", "cards proving, day 10"), ("prove_d20", "cards proving, day 20"), ("prove_d30", "cards proving, day 30"),
|
|
("hyb_d30", "cards hybrid, day 30"), ("off_d30", "cards off, day 30"),
|
|
("hash_min_ratio", "hash min / pre-event"), ("hash_d30_ratio", "hash day 30 / pre-event"), ("hours_hash_lt50", "hours hash under 50%"),
|
|
("q_max", "backlog max, shards"), ("age_max", "oldest unproven age max, s"),
|
|
("age_d10", "age max day 10, s"), ("age_d20", "age max day 20, s"), ("age_d30", "age max day 30, s"),
|
|
("ok60_mean", "blocks proven within 60 s"), ("ok60_min_day", "worst day within 60 s"), ("ok20_mean", "blocks proven within 20 s"),
|
|
("bps_mean", "blocks/s mean"), ("bps_max", "blocks/s hourly max"), ("bps_min", "blocks/s hourly min"), ("D_end_over_start", "difficulty end / start"),
|
|
("ext_delivered", "external jobs delivered"), ("price_end", "price at day 30, $"),
|
|
("osc_cross_per_day", "proving-share crossings per day"), ("osc_amp_pts", "proving-share 10-90 pct range, points"),
|
|
("switches", "mode switches (operator x class)"),
|
|
]
|
|
scen = list(out)
|
|
lines = ["| Metric | " + " | ".join(f"{s} | {s} min | {s} max" for s in scen) + " |",
|
|
"|---|" + "---|" * (3 * len(scen))]
|
|
for k, label in keys:
|
|
row = [label]
|
|
for s in scen:
|
|
m, lo, hi = agg(out[s], k)
|
|
nd = 4 if k == "price_end" else 2
|
|
row += [fmt(float(m), nd), fmt(float(lo), nd), fmt(float(hi), nd)]
|
|
lines.append("| " + " | ".join(row) + " |")
|
|
print("\n".join(lines))
|
|
print()
|
|
print("| Flag | " + " | ".join(scen) + " |")
|
|
print("|---|" + "---|" * len(scen))
|
|
for k, label in [("miner_shortage", "miner shortage (hash under 50% of pre-event for 1 h or more)"),
|
|
("backlog_600", "backlog over 600 s (design's backlog-rule trigger)"),
|
|
("backlog_growing", "growing backlog (positive 10-day trend and over 60 s at day 30)"),
|
|
("window_miss", "window miss (a day under 90% of blocks within 60 s)"),
|
|
("oscillation", "oscillation (proving-share 10-90 range over 10 points in the last 10 days)")]:
|
|
row = [label]
|
|
for s in scen:
|
|
n = sum(1 for m in out[s] if m[k])
|
|
row.append(f"{n}/{len(out[s])}")
|
|
print("| " + " | ".join(row) + " |")
|
|
print()
|
|
print("Operator profit by card class, $ per card-day (mean over seeds, all modes including off):")
|
|
print()
|
|
print("| Scenario | " + " | ".join(CLS) + " | shard share " + " | shard share ".join(CLS[:3]) + " |")
|
|
print("|---|" + "---|" * 7)
|
|
for s in scen:
|
|
pr = np.mean([m["profit_card_day"] for m in out[s]], 0)
|
|
sh = np.mean([m["shard_share"] for m in out[s]], 0)
|
|
print(f"| {s} | " + " | ".join(f"{v:.3f}" for v in pr) + " | " + " | ".join(f"{v:.2f}" for v in sh[:3]) + " |")
|
|
print()
|
|
|
|
|
|
def lever_study(scenario, seeds, p, with_sens=True, sens_only=False):
|
|
levers = {
|
|
"pool": [0.10, 0.20, 0.30, 0.40],
|
|
"window": [5.0, 10.0, 20.0, 30.0],
|
|
"burn": [0.0, 0.10, 0.25, 0.50],
|
|
"timeout": [60.0, 120.0, 300.0, 600.0],
|
|
"shards_per_block": [3.0, 30.0, 100.0, 300.0],
|
|
"ema_ticks": [7.0, 20.0, 60.0, 480.0],
|
|
}
|
|
if sens_only:
|
|
levers = {k: v for k, v in levers.items() if k in ("shards_per_block", "ema_ticks")}
|
|
elif not with_sens:
|
|
levers = {k: v for k, v in levers.items() if k not in ("shards_per_block", "ema_ticks")}
|
|
print(f"Lever study on scenario {scenario} ({SCEN[scenario]}), seeds {list(seeds)}, traffic {p['shards_per_block']:.0f} shards per block. One parameter at a time, the rest at the design values.")
|
|
print()
|
|
print("Rows pool, window, burn, timeout are protocol levers; shards_per_block (chain traffic) and ema_ticks (operators' observation window, ticks of 180 s) are model sensitivities.")
|
|
print()
|
|
print("| Lever | Value | hash min / pre | hash d30 / pre | hours hash under 50% | age max s | within 60 s (mean) | worst day within 60 s | cards proving d30 | cards off d30 | external delivered | 3060 shard share | balance score |")
|
|
print("|---|---|---|---|---|---|---|---|---|---|---|---|---|")
|
|
rows = []
|
|
for lever, vals in levers.items():
|
|
for v in vals:
|
|
pp = dict(p)
|
|
pp[lever] = v
|
|
out = run_scenarios([scenario], seeds, pp, quiet=True)[scenario]
|
|
m = {k: agg(out, k)[0] for k in ["hash_min_ratio", "hash_d30_ratio", "hours_hash_lt50", "age_max", "ok60_mean", "ok60_min_day", "prove_d30", "off_d30", "ext_delivered"]}
|
|
sh = np.mean([x["shard_share"] for x in out], 0)[2]
|
|
# balance score: mean of (hash d30 ratio capped at 1), worst-day proof share, 1 - age_max/600 capped
|
|
score = (min(1.0, m["hash_d30_ratio"]) + m["ok60_min_day"] + max(0.0, 1 - m["age_max"] / 600.0)) / 3.0
|
|
star = " (design)" if abs(v - P[lever]) < 1e-9 and lever in ("pool", "window", "burn", "timeout") else (" (default)" if abs(v - P[lever]) < 1e-9 else "")
|
|
print(f"| {lever} | {v}{star} | {m['hash_min_ratio']:.2f} | {m['hash_d30_ratio']:.2f} | {m['hours_hash_lt50']:.0f} | {m['age_max']:.0f} | {m['ok60_mean']:.3f} | {m['ok60_min_day']:.3f} | {m['prove_d30']:.3f} | {m['off_d30']:.3f} | {m['ext_delivered']:.2f} | {sh:.2f} | {score:.3f} |")
|
|
rows.append((lever, v, score))
|
|
sys.stdout.flush()
|
|
print()
|
|
return rows
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--scenarios", default="a,b,c,d,e,f")
|
|
ap.add_argument("--seeds", type=int, default=5)
|
|
ap.add_argument("--seed0", type=int, default=1)
|
|
ap.add_argument("--levers", default=None, help="scenario letter for the lever study")
|
|
ap.add_argument("--sens", action="store_true", help="with --levers: only the two sensitivities (traffic, observation window)")
|
|
ap.add_argument("--nosens", action="store_true", help="with --levers: protocol levers only")
|
|
ap.add_argument("--set", default="", help="k=v,k=v parameter overrides")
|
|
ap.add_argument("--dump", default=None, help="scenario letter: print the hourly CSV for the first seed")
|
|
ap.add_argument("--days", type=int, default=None)
|
|
args = ap.parse_args()
|
|
p = dict(P)
|
|
for kv in filter(None, args.set.split(",")):
|
|
k, v = kv.split("=")
|
|
p[k] = float(v)
|
|
if args.days:
|
|
p["days"] = args.days
|
|
seeds = range(args.seed0, args.seed0 + args.seeds)
|
|
if args.dump:
|
|
sim = Sim(args.dump, args.seed0, p)
|
|
h = sim.run()
|
|
print("hour," + ",".join(COLS))
|
|
for i, row in enumerate(h):
|
|
print(f"{i}," + ",".join(f"{v:.6g}" for v in row))
|
|
m = metrics(h, sim)
|
|
for k, v in m.items():
|
|
print(f"# {k}: {v}", file=sys.stderr)
|
|
return
|
|
if args.levers:
|
|
lever_study(args.levers, seeds, p, with_sens=not args.nosens, sens_only=args.sens)
|
|
return
|
|
scen = args.scenarios.split(",")
|
|
print(f"# Igneum economy simulation, {p['n_ops']} operators, {p['days']} days, seeds {list(seeds)}, tick {p['tick']:.0f} s")
|
|
print()
|
|
print("Parameters: " + ", ".join(f"{k}={v}" for k, v in p.items()))
|
|
print()
|
|
for s in scen:
|
|
print(f"- {s}: {SCEN[s]}")
|
|
print()
|
|
t0 = time.time()
|
|
out = run_scenarios(scen, seeds, p)
|
|
table_scenarios(out)
|
|
print(f"<!-- total {time.time() - t0:.0f} s -->")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|