tune_mtp.py raw

   1  #!/usr/bin/env python3
   2  """
   3  Limenka PID gain tuner.
   4  
   5  Sweeps (kp, ki, kd, window, ebasis) over the scenarios and prints a
   6  scoreboard.  The tuning goals, in priority order:
   7  
   8    1. bounded difficulty noise in steady state (no limit cycle, d wanders
   9       < ~+-20% with no hashrate forcing)
  10    2. fast tracking: time for d to reach 2x on a 3x hashrate step
  11    3. flood resistance: low d damage from a 30-min 50x flood and a short
  12       stall window after exit
  13    4. issuance parity (should be ~1.000 by construction; verify)
  14  
  15  Run:  python3 tune_mtp.py --ebasis direct
  16  """
  17  import argparse
  18  import math
  19  import os
  20  import random
  21  import sys
  22  
  23  sys.path.insert(0, os.path.dirname(__file__))
  24  import mtp_sim as M
  25  
  26  
  27  class Cfg:
  28      def __init__(self, window, future, kp, ki, kd, alpha, lp, fill, ebasis,
  29                   seed, blocks):
  30          self.window = window
  31          self.future = future
  32          self.kp = kp
  33          self.ki = ki
  34          self.kd = kd
  35          self.alpha = alpha
  36          self.lp = lp
  37          self.fill = fill
  38          self.ebasis = ebasis
  39          self.seed = seed
  40          self.blocks = blocks
  41          self.scenario = "steady"
  42          self.drop = 0.9
  43          self.flood_x = 50
  44          self.flood_min = 30
  45          self.stall_frac = 0.001
  46          self.stall_days = 4
  47  
  48  
  49  def steady_wander(cfg):
  50      """log-std of d in steady state (no forcing) after warmup."""
  51      rng = random.Random(cfg.seed)
  52      world = M.World()
  53      world.const(0, 1.0)
  54      chain = M.Chain(cfg)
  55      chain.add_block(0.0, False, seed=True)
  56      for _ in range(cfg.blocks):
  57          h, a, _mode = world.rates_at(chain.t)
  58          dt = rng.expovariate(h / (M.T * chain.d))
  59          chain.add_block(chain.t + dt, False)
  60      dlogs = [math.log(b[3]) for b in chain.blocks[cfg.blocks // 3:]]
  61      mean = sum(dlogs) / len(dlogs)
  62      var = sum((x - mean) ** 2 for x in dlogs) / len(dlogs)
  63      return math.sqrt(var), chain.d
  64  
  65  
  66  def step_time(cfg, step_x=3.0):
  67      """hours for d to reach 2.0 after a step to step_x at 20 days."""
  68      rng = random.Random(cfg.seed)
  69      world = M.World()
  70      step_t = 20 * 86400
  71      world.const(0, 1.0)
  72      world.const(step_t, step_x)
  73      chain = M.Chain(cfg)
  74      chain.add_block(0.0, False, seed=True)
  75      t50 = None
  76      for _ in range(cfg.blocks):
  77          h, a, _mode = world.rates_at(chain.t)
  78          dt = rng.expovariate(h / (M.T * chain.d))
  79          arr = chain.t + dt
  80          nxt = world.next_boundary(chain.t)
  81          if nxt is not None and arr >= nxt:
  82              chain.advance_time(nxt)
  83              continue
  84          chain.add_block(arr, False)
  85          if chain.t > step_t and t50 is None and chain.d >= 2.0:
  86              t50 = (chain.t - step_t) / 3600
  87      return t50
  88  
  89  
  90  def flood_metrics(cfg):
  91      """d damage, attacker reward, stall window for a 30-min 50x flood."""
  92      rng = random.Random(cfg.seed)
  93      world = M.World()
  94      exit_t = 100 * M.T + cfg.flood_min * 60
  95      world.const(0, 1.0)
  96      world.const(100 * M.T, 1.0, cfg.flood_x)
  97      world.const(exit_t, 1.0)
  98      chain = M.Chain(cfg)
  99      chain.add_block(0.0, False, seed=True)
 100      for _ in range(cfg.blocks):
 101          h, a, _mode = world.rates_at(chain.t)
 102          total = h + a
 103          if total <= 0:
 104              break
 105          dt = rng.expovariate(total / (M.T * chain.d))
 106          arr = chain.t + dt
 107          nxt = world.next_boundary(chain.t)
 108          if nxt is not None and arr >= nxt:
 109              chain.advance_time(nxt)
 110              continue
 111          chain.add_block(arr, rng.random() < a / total)
 112      d_max = max(b[3] for b in chain.blocks)
 113      post = [b for b in chain.blocks if b[0] >= exit_t and not b[1]]
 114      restore = None
 115      if post:
 116          good = 0
 117          for b in post:
 118              if abs(b[2] - M.T) <= 0.5 * M.T:
 119                  good += 1
 120              else:
 121                  good = 0
 122              if good >= 3:
 123                  restore = (b[0] - exit_t) / 3600
 124                  break
 125      return d_max, chain.attacker_reward, restore
 126  
 127  
 128  def main():
 129      ap = argparse.ArgumentParser(description="Limenka PID tuner")
 130      ap.add_argument("--window", type=int, default=101)
 131      ap.add_argument("--ebasis", choices=["mtp", "direct"], default="direct")
 132      ap.add_argument("--seed", type=int, default=7)
 133      ap.add_argument("--blocks", type=int, default=9000)
 134      args = ap.parse_args()
 135  
 136      gains = [
 137          (5e6, 5e6, 0),
 138          (10e6, 10e6, 0),
 139          (20e6, 10e6, 0),
 140          (50e6, 10e6, 0),
 141          (50e6, 20e6, 0),
 142          (20e6, 20e6, 0),
 143          (100e6, 20e6, 0),
 144          (50e6, 10e6, 16.7e6),
 145      ]
 146      print(f"window={args.window} ebasis={args.ebasis} seed={args.seed}")
 147      print(f"{'kp':>6} {'ki':>6} {'kd':>6} | {'wander':>6} {'step_t50':>8} | "
 148            f"{'flood_dmax':>10} {'flood_rew':>9} {'restore':>7}")
 149      for kp, ki, kd in gains:
 150          cfg = Cfg(args.window, 60, kp, ki, kd, 0.10, 0.15, 6600,
 151                    args.ebasis, args.seed, args.blocks)
 152          wander, _ = steady_wander(cfg)
 153          t50 = step_time(cfg)
 154          dmax, arew, restore = flood_metrics(cfg)
 155          def f(x):
 156              return "   nan" if x is None else f"{x:7.2f}"
 157          print(f"{kp / 1e6:6.1f}M {ki / 1e6:6.1f}M {kd / 1e6:6.1f}M | "
 158                f"{wander:6.3f} {f(t50)} | {dmax:10.2f} {arew:9.2f} {f(restore)}")
 159  
 160  
 161  if __name__ == "__main__":
 162      main()
 163