341 lines
18 KiB
Python
Raw Normal View History

2026-07-30 14:45:21 +02:00
"""Reference difficulty (effort `E`) controllers + a simulator.
Two closed-loop controllers that adjust the Equi-X effort parameter to demand,
calibrated on the MEASURED mint-rate curve (docs/findings.md §7a):
* MintRateController (Design A) hold a network's token MINT RATE at a target,
the way PoW difficulty retargeting works, specialized to the measured 1/E law.
* LoadController (Design B) hold a single node's admission PRESSURE (offered
valid-token rate ÷ service capacity) at a target, so an attack is throttled
while honest clients pay the least difficulty that keeps the node healthy.
Both rest on one measured fact: token rate 1/E on fixed hardware, and verify
cost is constant and ~free. So the control law is a cheap MULTIPLICATIVE step and
needs no absolute capacity model it self-corrects from what it observes.
Run the demo (writes plots + a summary):
python -m equix_bench.difficulty_control --out results/control
"""
from __future__ import annotations
import math
import random
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Optional
# Measured mint rate per reference machine (Apple M4 Pro, 14 cores, Rust-JIT),
# from results/main/mining.csv: tokens/second at each effort target. POOLED
# estimator (total tokens over total busy seconds across all workers) — a sum
# of per-worker 1/t ratios would carry a +6..25% upward bias at these CVs.
MEASURED_MINT: list[tuple[int, float]] = [(100, 69.1), (300, 23.5), (1000, 5.49), (3000, 1.75), (10000, 0.67)]
def mint_rate_per_machine(E: float, points: list[tuple[int, float]] = MEASURED_MINT) -> float:
"""Tokens/s one reference machine mints at effort `E`.
Log-log linear interpolation between the measured points; outside the
measured range it extrapolates as pure 1/E (slope -1 in log-log), the design
asymptote. Monotonically decreasing in E."""
pts = sorted(points)
if E <= pts[0][0]:
e0, r0 = pts[0]
return r0 * (e0 / E) # 1/E extrapolation below range
if E >= pts[-1][0]:
e1, r1 = pts[-1]
return r1 * (e1 / E) # 1/E extrapolation above range
for (e0, r0), (e1, r1) in zip(pts, pts[1:]):
if e0 <= E <= e1:
f = (math.log(E) - math.log(e0)) / (math.log(e1) - math.log(e0))
return math.exp(math.log(r0) + f * (math.log(r1) - math.log(r0)))
return pts[-1][1] # unreachable
def _clip(x: float, lo: float, hi: float) -> float:
return max(lo, min(hi, x))
def equilibrium_E(machines: float, target_rate: float,
points: list[tuple[int, float]] = MEASURED_MINT,
lo: float = 1.0, hi: float = 1e9) -> float:
"""The E at which `machines` reference machines mint `target_rate` tokens/s
(the natural controller seed). Solved by bisection on the measured curve
no hard-coded constants to drift out of sync when MEASURED_MINT is updated."""
for _ in range(200):
mid = math.sqrt(lo * hi) # geometric: the curve lives in log-log space
if machines * mint_rate_per_machine(mid, points) > target_rate:
lo = mid
else:
hi = mid
if hi / lo < 1.0001:
break
return math.sqrt(lo * hi)
@dataclass
class MintRateController:
"""Design A: E ← E · clamp(R_obs / R*). Because rate ∝ 1/E, minting twice too
fast means E must double. Robust to capacity drift (only uses observed rate)."""
target_rate: float # R* (tokens/s the network should mint)
E: float = 1000.0 # current difficulty
e_min: float = 100.0
e_max: float = 1e9
max_factor: float = 4.0 # per-epoch clamp (anti-oscillation, Bitcoin-style)
ewma: float = 0.3 # smoothing of the observed rate (0..1); 1 = no smoothing
_r: Optional[float] = None # smoothed observed rate
def update(self, observed_rate: float) -> float:
self._r = observed_rate if self._r is None else self.ewma * observed_rate + (1 - self.ewma) * self._r
factor = _clip((self._r / self.target_rate) if self.target_rate > 0 else 1.0,
1.0 / self.max_factor, self.max_factor)
self.E = _clip(self.E * factor, self.e_min, self.e_max)
return self.E
@dataclass
class LoadController:
"""Design B: E ← E · clamp(exp(k · (p - p_set))), where p = offered valid-token
rate ÷ service capacity. Raise E under pressure (throttle), let it decay toward
e_min when idle (cheap for honest clients). Deadband avoids flapping note the
intended consequence: while pressure sits within the deadband of p_set, E HOLDS
(including post-attack, if load keeps utilization pinned at target); it only
decays once pressure drops below p_set - deadband."""
p_set: float = 0.8 # target pressure (headroom below saturation)
k: float = 1.5 # gain
E: float = 300.0
e_min: float = 300.0
e_max: float = 1e9
max_factor: float = 4.0
deadband: float = 0.03
def update(self, pressure: float) -> float:
e = pressure - self.p_set
if abs(e) < self.deadband:
return self.E
factor = _clip(math.exp(self.k * e), 1.0 / self.max_factor, self.max_factor)
self.E = _clip(self.E * factor, self.e_min, self.e_max)
return self.E
# --------------------------------------------------------------------- simulators
@dataclass
class Trace:
t: list[float] = field(default_factory=list)
E: list[float] = field(default_factory=list)
signal: list[float] = field(default_factory=list) # observed rate (A) or pressure (B)
target: list[float] = field(default_factory=list)
extra: dict = field(default_factory=dict)
def simulate_mining(capacity: Callable[[int], float], target_rate: float, steps: int,
ctrl: Optional[MintRateController] = None,
rate_model: Callable[[float], float] = mint_rate_per_machine,
noise: float = 0.0, seed: int = 0) -> Trace:
"""Network of `capacity(t)` machines; controller holds mint rate at target.
`noise` (fractional stddev) models real measurement jitter on the observed
rate; `seed` makes it reproducible."""
ctrl = ctrl or MintRateController(target_rate=target_rate)
rng = random.Random(seed)
tr = Trace(target=[], extra={"capacity": [], "true_rate": []})
for t in range(steps):
C = capacity(t)
R = C * rate_model(ctrl.E) # true tokens/s at current E
R_obs = R * (1 + rng.gauss(0, noise)) if noise else R
tr.t.append(t); tr.E.append(ctrl.E); tr.signal.append(R_obs)
tr.target.append(target_rate); tr.extra["capacity"].append(C)
tr.extra["true_rate"].append(R) # noise-free, for honest error stats
ctrl.update(R_obs) # set E for the next epoch
return tr
def simulate_dos(legit: Callable[[int], float], attackers: Callable[[int], float],
service_capacity: float, steps: int, ctrl: Optional[LoadController] = None,
honest_cores: float = 1.0,
rate_model: Callable[[float], float] = mint_rate_per_machine,
adaptive_attackers: Optional[Callable[[int, float], float]] = None) -> Trace:
"""One protected node: honest req/s `legit(t)` plus attacker machines each
minting valid tokens at the current E. Controller holds admission pressure at
p_set. Records honest latency = time for one honest `honest_cores`-core client
to mint a token at the current E (the cost of defense to legitimate users).
`adaptive_attackers(t, E)`, when given, replaces `attackers(t)`: the attacker
OBSERVES the current difficulty and can pause when solving is too expensive
and resume when E decays the rational strategy against a decaying controller."""
ctrl = ctrl or LoadController()
tr = Trace(target=[], extra={"attacker_rate": [], "presented": [], "honest_latency": [], "util": []})
machine_cores = 14.0 # the reference machine the curve was measured on
for t in range(steps):
A = adaptive_attackers(t, ctrl.E) if adaptive_attackers else attackers(t)
m = rate_model(ctrl.E) # tokens/s per 14-core machine at current E
attacker_rate = A * m # valid tokens/s the attacker can present
presented = legit(t) + attacker_rate
pressure = presented / service_capacity
honest_latency = 1.0 / (m * honest_cores / machine_cores) # 1 honest client's token time
tr.t.append(t); tr.E.append(ctrl.E); tr.signal.append(pressure); tr.target.append(ctrl.p_set)
tr.extra["attacker_rate"].append(attacker_rate)
tr.extra["presented"].append(presented)
tr.extra["honest_latency"].append(honest_latency)
tr.extra["util"].append(min(1.0, pressure))
ctrl.update(pressure)
return tr
# ------------------------------------------------------------------------- demo
def _plot_mining(tr: Trace, path: Path, title: str = "Design A — mint-rate controller") -> None:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fig, ax = plt.subplots(3, 1, figsize=(8, 7), sharex=True)
ax[0].plot(tr.t, tr.extra["capacity"], color="#8172B3")
ax[0].set_ylabel("miners\n(machines)"); ax[0].set_title(title)
ax[1].plot(tr.t, tr.signal, color="#4C72B0", label="observed mint rate")
ax[1].plot(tr.t, tr.target, "--", color="#C44E52", label="target R*")
ax[1].set_ylabel("tokens / s"); ax[1].legend(fontsize=8)
ax[2].plot(tr.t, tr.E, color="#55A868"); ax[2].set_yscale("log")
ax[2].set_ylabel("effort E"); ax[2].set_xlabel("epoch")
for a in ax: a.grid(True, alpha=0.3)
fig.tight_layout(); fig.savefig(path, dpi=110); plt.close(fig)
def _plot_dos(tr: Trace, S: float, path: Path,
title: str = "Design B — single-node load controller") -> None:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fig, ax = plt.subplots(4, 1, figsize=(8, 9), sharex=True)
ax[0].plot(tr.t, tr.extra["presented"], color="#DD8452", label="offered valid-token req/s")
ax[0].plot(tr.t, tr.extra["attacker_rate"], ":", color="#C44E52", label="attacker share")
ax[0].axhline(S, ls="--", color="#333", label=f"service capacity S={S:g}")
ax[0].set_ylabel("requests / s"); ax[0].set_title(title); ax[0].legend(fontsize=8)
ax[1].plot(tr.t, tr.extra["util"], color="#4C72B0", label="utilization")
ax[1].plot(tr.t, tr.target, "--", color="#C44E52", label="p_set")
ax[1].set_ylabel("utilization"); ax[1].set_ylim(0, 1.05); ax[1].legend(fontsize=8)
ax[2].plot(tr.t, tr.E, color="#55A868"); ax[2].set_yscale("log"); ax[2].set_ylabel("effort E")
ax[3].plot(tr.t, tr.extra["honest_latency"], color="#937860")
ax[3].set_ylabel("honest solve\ntime (s)"); ax[3].set_xlabel("tick")
for a in ax: a.grid(True, alpha=0.3)
fig.tight_layout(); fig.savefig(path, dpi=110); plt.close(fig)
def main(argv=None) -> int:
import argparse
p = argparse.ArgumentParser(description="Equi-X difficulty (E) controller demo")
p.add_argument("--out", default="results/control", help="output directory for plots")
p.add_argument("--steps", type=int, default=80)
args = p.parse_args(argv)
out = Path(args.out); out.mkdir(parents=True, exist_ok=True)
# Design A: miners ramp 2→12, then a hashpower spike to 20 at 60%. Hold R* = 2 tok/s.
def capacity(t: int) -> float:
if t < 48:
return 2 + 10 * (t / 48)
return 20.0
mining = simulate_mining(capacity, target_rate=2.0, steps=args.steps)
_plot_mining(mining, out / "control_mining.png")
# Design B: honest 8 req/s throughout; a 6-machine flood during [24, 56). S = 40 req/s.
def legit(t: int) -> float:
return 8.0
def attackers(t: int) -> float:
return 6.0 if 24 <= t < 56 else 0.0
S = 40.0
dos = simulate_dos(legit, attackers, service_capacity=S, steps=args.steps)
_plot_dos(dos, S, out / "control_dos.png")
# Production run: how you'd actually deploy Design A. Gentle gains (max_factor
# 2, heavy EWMA), E SEEDED near equilibrium from the capacity estimate (no cold
# start), organic capacity growth, and ±8% measurement noise on the observed
# rate. Result: smooth tracking with no overshoot, stable under noise.
steps_p = max(args.steps, 120)
C0, R_target = 4.0, 2.0
E_seed = equilibrium_E(C0, R_target) # seed at equilibrium: C0 machines mint R_target
def capacity_prod(t: int) -> float:
# organic growth 4 → ~13 with a slow wobble (diurnal-like), no hard steps
return 4.0 + 9.0 * (t / steps_p) + 0.8 * math.sin(t / 9.0)
prod_ctrl = MintRateController(target_rate=R_target, E=E_seed, max_factor=2.0, ewma=0.15)
prod = simulate_mining(capacity_prod, target_rate=R_target, steps=steps_p,
ctrl=prod_ctrl, noise=0.08, seed=1)
_plot_mining(prod, out / "control_production.png",
title="Production run — mint-rate controller (seeded E, gentle gains, ±8% noise)")
# Summary numbers.
def _final(tr, key_signal):
return tr.E[-1], key_signal
# Run 4 — ADAPTIVE attacker vs Design B: 6 machines that only attack while
# E is below their give-up point (solving cheap enough to bother), pausing
# when the controller escalates and resuming as E decays. The rational
# strategy — does the loop oscillate, and what does the attacker still get?
E_GIVEUP = 800.0
def adaptive(t: int, E: float) -> float:
if t < 16 or t >= 104:
return 0.0
return 6.0 if E < E_GIVEUP else 0.0
adaptive_tr = simulate_dos(legit, lambda t: 0.0, service_capacity=S,
steps=120, adaptive_attackers=adaptive)
_plot_dos(adaptive_tr, S, out / "control_adaptive.png",
title="Design B vs an ADAPTIVE attacker (attacks only while E < give-up)")
atk_window = adaptive_tr.extra["attacker_rate"][16:104]
duty = sum(1 for a in atk_window if a > 0) / len(atk_window)
sat = sum(1 for u in adaptive_tr.extra["util"][16:104] if u >= 0.999)
# Run 5 — miner CHURN for Design A production tuning: miners join/leave as a
# seeded random walk instead of a smooth ramp; noise on the observed rate.
rng_churn = random.Random(7)
miners = 8.0
churn_path = []
for _ in range(140):
if rng_churn.random() < 0.15: miners += 1
if rng_churn.random() < 0.15 and miners > 2: miners -= 1
churn_path.append(miners)
churn_ctrl = MintRateController(target_rate=R_target, E=equilibrium_E(8.0, R_target),
max_factor=2.0, ewma=0.15)
churn = simulate_mining(lambda t: churn_path[t], target_rate=R_target, steps=140,
ctrl=churn_ctrl, noise=0.08, seed=2)
_plot_mining(churn, out / "control_churn.png",
title="Production tuning under miner churn (join/leave random walk, ±8% noise)")
churn_tail = churn.extra["true_rate"][40:]
churn_err = 100.0 * (sum(churn_tail) / len(churn_tail) - R_target) / R_target
# Production-run steady-state error over the settled tail (last third).
# Report BOTH: the noisy observed mean (what an operator sees) and the
# noise-free true-rate mean (the controller's systematic tracking lag —
# averaging only the noisy signal would understate it).
n_tail = len(prod.signal) * 2 // 3
obs_tail = prod.signal[n_tail:]
true_tail = prod.extra["true_rate"][n_tail:]
prod_err_obs = 100.0 * (sum(obs_tail) / len(obs_tail) - R_target) / R_target
prod_err_true = 100.0 * (sum(true_tail) / len(true_tail) - R_target) / R_target
lines = ["# Difficulty-control simulation\n",
f"- Design A (mining): final E={mining.E[-1]:,.0f}, "
f"mint rate {mining.signal[-1]:.2f} tok/s vs target {mining.target[-1]:.2f} "
f"(with {mining.extra['capacity'][-1]:.0f} miners).",
f"- Design B (DoS): peak E under attack ≈ {max(dos.E):,.0f}; "
f"steady honest solve time {dos.extra['honest_latency'][0]:.2f}s at rest → "
f"{max(dos.extra['honest_latency']):.2f}s under attack; "
f"utilization held near p_set={dos.target[-1]:.2f}.",
f"- Production run (seeded E, gentle gains, ±8% noise): no cold-start "
f"overshoot; settled tail within {prod_err_obs:+.1f}% of target (observed) / "
f"{prod_err_true:+.1f}% (noise-free — the systematic lag against the capacity "
f"ramp), final E={prod.E[-1]:,.0f} at {prod.extra['capacity'][-1]:.0f} miners.",
f"- Adaptive attacker (give-up E={E_GIVEUP:,.0f}): duty-cycled to "
f"{duty*100:.0f}% attack-on time, {sat} saturated tick(s) in the whole attack "
f"window; E oscillates {min(adaptive_tr.E[20:104]):,.0f}{max(adaptive_tr.E[20:104]):,.0f} "
f"(sawtooth around the give-up point — see the doc for the mitigation).",
f"- Miner churn (random walk {min(churn_path):.0f}{max(churn_path):.0f} miners): "
f"settled noise-free rate within {churn_err:+.1f}% of target.",
"\nPlots: `control_mining.png`, `control_dos.png`, `control_production.png`, "
"`control_adaptive.png`, `control_churn.png`.\n"]
(out / "summary.md").write_text("\n".join(lines))
print("\n".join(lines))
print(f"\nWrote {out}/control_mining.png, {out}/control_dos.png, "
f"{out}/control_production.png, {out}/summary.md")
return 0
if __name__ == "__main__":
raise SystemExit(main())