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