Economy: agent-based mining-versus-proving simulation, six stress scenarios, lever study, analysis and bench entry
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>
This commit is contained in:
parent
017314f52d
commit
c1a44387ee
6 changed files with 1129 additions and 0 deletions
205
docs/analysis/economy-2026-10-04.md
Normal file
205
docs/analysis/economy-2026-10-04.md
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
# Igneum economy: mining versus proving under stress, agent-based simulation
|
||||
|
||||
4 October 2026. Model, not hardware. Simulator `sim/economy/sim.py`, raw output `sim/economy/results.md` (six scenarios, 5 seeds) and `sim/economy/levers.md` (lever study and sensitivities on the worst scenario). Answers the external reviewer's point 3 (round 3, operator of a large GPU farm; ledger P8, P9, E6, M14): do the pricing, rewards, capacity limits and recovery rules keep both roles filled under stress, or does the system oscillate between shortages?
|
||||
|
||||
Every number in this document is a model output or a model assumption. The one measured input is the RTX 5090 lottery hash rate, 229 MH/s (`docs/bench-log.md`, RTX 5090 first run). Everything else is labelled approximate in the assumptions table and should be replaced by the phase 2 and phase 4 measurements as they land.
|
||||
|
||||
## 1. The question and the thresholds, fixed before running
|
||||
|
||||
Thresholds, defined before the first run:
|
||||
|
||||
| Id | Failure | Threshold |
|
||||
|---|---|---|
|
||||
| T1 | Miner shortage that threatens security | Total hash under 50% of the scenario's own days 1 to 7 mean for one hour or more (half the pre-event hash is the point where a renter of the pre-event size holds a majority) |
|
||||
| T2 | Backlog | Oldest unproven block older than 600 s at any time (the design's own backlog-rule trigger, `docs/design/execution-layer.md` 4.3) |
|
||||
| T3 | Growing backlog | Daily maximum of the oldest unproven age has a positive linear trend over the last 10 days and is above 60 s on day 30 |
|
||||
| T4 | Window miss | Any day with under 90% of blocks proven within 60 s of the block (the design's 20 to 60 s lag) |
|
||||
| T5 | Oscillation | The share of cards in proving mode has a 10th-to-90th percentile range over 10 points across the last 10 days |
|
||||
|
||||
## 2. The model
|
||||
|
||||
Agent-based, 1,000 operators, 30 simulated days, ticks of 180 s, 5 seeds per scenario. Each operator owns a fleet of cards in four classes and sets a mode per class with its own client, every 12 minutes at its own phase, by comparing expected profit per card-hour:
|
||||
|
||||
| Mode | What the card does | Who can |
|
||||
|---|---|---|
|
||||
| MINE | Hashes; earns its hash share of the 80% producer emission | Every class |
|
||||
| PROVE | Proving-ready; answers shard and job assignments, races open claims; idle power while waiting | 5090, 3090, 3060 |
|
||||
| HYBRID | Hashes; swaps program (5 s each way, approximate) to answer its own assignments and open claims it can win | 5090, 3090 only (the mining dataset and the prover must both be resident; a 12 GB card cannot hold both, approximate) |
|
||||
| OFF | Nothing | Every class |
|
||||
|
||||
Per tick the market resolves in this order: hash rate; a difficulty tracker with the spec 2.3 clamps (3% harden, 10% ease per block, 120-block estimate); Poisson blocks; blocks attributed by hash (multinomial); each operator's 30-day weight (its mined blocks, the finality-rule population of spec 7.2); internal shards (Poisson, 3 per block at launch traffic); external jobs (Poisson, dollars); for each class of work, sortition of 8 assignees by weight with a 10-s exclusive window, assignee responds if it has proving-ready or hybrid capacity, the assignee's proof wins when it lands before the fastest open claimer's (window plus the fastest responder's shard time), else the shard is open and the fastest responder wins, race losers waste half an attempt per open shard; a backlog queue served by any capacity; the proving pool (20% of emission) paid per block divided by the block's shards; external jobs paid in dollars with 10% burned; electricity by card, mode and busy fraction; a GBM coin price. Operators observe the last hour (EMA) of realised rates: mining income per hash, open-claim income per proving card by class, assigned income per unit of weight (internal and external separately), and value PROVE and HYBRID at their own weight. A switch needs a gain above the operator's own hysteresis (5 to 25%) and at least an hour since its last switch. Operators are myopic: they do not value the weight that mining builds for future assignments.
|
||||
|
||||
What the model keeps from the design: the 80/20 split, the fixed pool per block divided by shards, sortition by weight with 8 assignees and a 10-s window then open claiming with no bond (spec 7.2), the eligibility population (spec 3), the 90/10 external split (spec 5.4), the backlog rule halving `B_p` per 600 s of oldest age (design 4.3), the claim timeout limiting which cards can take a job (design 6, O-5.6), the controller clamps (spec 2.3), emission per block (spec 2.5, ramp complete, pre-halving).
|
||||
|
||||
### 2.1 Assumptions table
|
||||
|
||||
| Item | Value | Label |
|
||||
|---|---|---|
|
||||
| Operators | 1,000; fleet size lognormal (median 5 cards, mean about 15); operator 0 is a farm of 5090s holding 20% of hash (30% in scenario e) at $0.05/kWh | Assumed |
|
||||
| Card mix by count | 5090 25%, 3090 25%, 3060 30%, small 20% | Approximate |
|
||||
| Hash rate | 5090 229 MH/s (Measured); 3090 57 (a quarter), 3060 46 (a fifth), small 25 | Approximate except the 5090 |
|
||||
| Power while hashing | 450, 320, 170, 120 W | Approximate |
|
||||
| Power while proving / idle-ready | 500 / 60, 350 / 50, 170 / 30 W | Approximate |
|
||||
| Shard time | 3060 20 s (the phase 2 gate target, Target, unmeasured); 3090 12 s, 5090 6 s scaled by throughput; small cards cannot prove (8 GB) | Approximate; ledger P1 |
|
||||
| Electricity | Lognormal around $0.10/kWh (sigma 0.4), larger fleets cheaper, clipped to $0.02 to $0.40 | Assumed |
|
||||
| Emission | 31.688 IGN per block, ramp complete, first halving period (spec 2.5) | Designed |
|
||||
| Coin price | $0.012 at t=0 (chosen so the median-electricity 3060 mines at a thin margin); 5% daily volatility, geometric | Assumed; only the ratio of price to electricity matters |
|
||||
| Internal traffic | 3 shards per block at launch; sensitivity at 30, 100, 300 | Assumed |
|
||||
| External demand | $2,000 per day in $50 jobs of 10 shard-equivalents (the customer brief's "low millions a year" market, a share of it) | Approximate |
|
||||
| External price shock (b) | Price per job x10 from day 7 | Scenario |
|
||||
| Program swap | 5 s each way (hybrid) | Approximate; ledger M11 measured 69 to 129 ms for the lottery kernel, the prover side is unmeasured |
|
||||
| Aggregation plus inclusion | 4 s added to every block proof | Approximate |
|
||||
| Open-claim waste | 0.5 wasted attempts per open shard | Assumed |
|
||||
| Claim timeout (external) | 300 s: a class may claim a job only if its shard time x job work fits | Assumed; O-5.6 is open |
|
||||
| Observation window | 1 h EMA; decisions every 12 min; 1 h minimum dwell; hysteresis 5 to 25% | Assumed; sensitivity at 20 min, 3 h, 24 h |
|
||||
| Pools | Small operators are treated as pooled for eligibility; the pool forwards assignments to members by weight | Assumed; no pool protocol exists (spec 9 pending) |
|
||||
| Burn | Reduces prover take only; no price effect modelled (the burn is 0.01% of supply per day at baseline) | Assumed |
|
||||
|
||||
### 2.2 Scenarios
|
||||
|
||||
| Id | Scenario | Event |
|
||||
|---|---|---|
|
||||
| a | Baseline | none |
|
||||
| b | External demand pays 10x while the coin price falls 70% over a week | from day 7; price falls days 7 to 14 |
|
||||
| c | No external demand | whole run |
|
||||
| d | The largest operator (20% of hash) disappears | day 10; its weight stays in the window |
|
||||
| e | A 30% operator never fulfils its assignments | whole run; it mines only, keeps its weight |
|
||||
| f | A pool with hash equal to the network's switches in (2x hash) | day 10; 200 new operators with no weight |
|
||||
|
||||
## 3. Results, six scenarios, 5 seeds
|
||||
|
||||
Means over seeds, with the seed minimum and maximum in `sim/economy/results.md`.
|
||||
|
||||
| Metric | a | b | c | d | e | f |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Hash share of potential hash that is mining (mean) | 0.93 | 0.85 | 0.96 | 0.92 | 0.91 | 0.93 |
|
||||
| Cards in PROVE mode, day 10 / day 30 | 0.15 / 0.15 | 0.27 / 0.34 | 0.08 / 0.09 | 0.15 / 0.13 | 0.22 / 0.21 | 0.15 / 0.17 |
|
||||
| Cards in HYBRID mode, day 30 | 0.54 | 0.45 | 0.54 | 0.45 | 0.42 | 0.50 |
|
||||
| Cards OFF, day 30 | 0.01 | 0.10 | 0.01 | 0.09 | 0.01 | 0.06 |
|
||||
| Hash minimum / pre-event mean | 0.95 | 0.82 | 0.97 | 0.75 | 0.98 | 0.95 |
|
||||
| Hash day 30 / pre-event mean | 1.00 | 0.87 | 1.00 | 0.80 | 1.00 | 1.92 |
|
||||
| Hours with hash under 50% (T1) | 0 | 0 | 0 | 0 | 0 | 0 |
|
||||
| Backlog maximum, shards | 0 | 0 | 0 | 0 | 0 | 0 |
|
||||
| Oldest unproven age maximum, s (T2) | 0 | 0 | 0 | 0 | 0 | 0 |
|
||||
| Blocks proven within 60 s, mean / worst day (T4) | 1.00 / 1.00 | 1.00 / 1.00 | 1.00 / 1.00 | 1.00 / 1.00 | 1.00 / 1.00 | 1.00 / 1.00 |
|
||||
| Blocks proven within 20 s | 0.26 | 0.41 | 0.29 | 0.21 | 0.14 | 0.27 |
|
||||
| Blocks per second, mean (hourly min to max) | 1.00 (0.95 to 1.05) | same | same | same | same | same |
|
||||
| Difficulty day 30 / day 1 | 1.00 | 0.86 | 1.00 | 0.80 | 1.00 | 1.91 |
|
||||
| External jobs delivered | 1.00 | 1.00 | none | 1.00 | 1.00 | 1.00 |
|
||||
| Proving-share 10-90 range, last 10 days, points (T5) | 8.5 | 7.4 | 2.8 | 8.5 | 3.8 | 5.7 |
|
||||
| Mode switches per operator-class per day | 2.0 | 1.2 | 1.7 | 2.1 | 1.0 | 2.1 |
|
||||
|
||||
Flags (seeds tripping / 5): T1 0 in every scenario; T2 0; T3 0; T4 0; T5 0 except f, 1 of 5 (range 10.1 points).
|
||||
|
||||
Operator profit by card class, $ per card-day, every mode including OFF, and the share of shards each class proves:
|
||||
|
||||
| Scenario | 5090 | 3090 | 3060 | small | shards 5090 | shards 3090 | shards 3060 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| a | 6.37 | 1.66 | 0.78 | 0.39 | 0.59 | 0.26 | 0.16 |
|
||||
| b | 5.17 | 1.82 | 1.36 | 0.15 | 0.63 | 0.19 | 0.18 |
|
||||
| c | 6.12 | 1.57 | 0.76 | 0.39 | 0.62 | 0.29 | 0.09 |
|
||||
| d | 6.48 | 2.34 | 1.06 | 0.57 | 0.53 | 0.31 | 0.16 |
|
||||
| e | 5.08 | 1.53 | 0.85 | 0.31 | 0.43 | 0.28 | 0.29 |
|
||||
| f | 3.35 | 0.69 | 0.43 | 0.15 | 0.56 | 0.25 | 0.19 |
|
||||
|
||||
### 3.1 What the runs show
|
||||
|
||||
1. **No backlog in any scenario, and no shortage of provers.** At launch traffic the chain needs about 3 shard-seconds of 3060 time per second; the fleet has thousands of proving-ready card-seconds. The 20% pool is a fixed subsidy per block, so provers are competing for a fixed pot, not filling a capacity. The equilibrium is a surplus of proving capacity in every scenario, including c (no external income) and e (30% of draws wasted). Every block is proven inside 60 s in every hour of every run.
|
||||
2. **The dominant strategy on 24 GB cards is HYBRID**: mine, answer your own assignments. About half of all cards end there in every scenario. It costs the card nothing while no assignment arrives, and sortition by weight hands assignments to the cards that mine. This is what the design intends ("the same cards") and it is what a profit-maximising client does on its own. The 20 to 60 s window is met with margin; the under-20-s share is 14 to 41% because a hybrid 3090 (12 s plus a 5 s swap) and a 3060 (20 s) cannot land inside 16 s.
|
||||
3. **The stress lands on hash, not on proofs.** Scenario b (price down 70%, external up 10x) takes 10% of cards off and moves a third into PROVE; hash troughs at 82% of its pre-event level and ends at 87%. Scenario d removes 20% by construction and the remaining operators do not fill the gap (hash 80% at day 30; the dead operator's weight wastes its draws for 30 days with no effect on the 60-s window). Neither reaches T1. The design's own observation (ledger C7) stands: hash follows price, and the proving income does not change that because it is 20% of the same emission.
|
||||
4. **No shortage oscillation.** The proving share moves 3 to 9 points across a day (T5 range), driven by 3060 owners parking in PROVE when mining is marginal and leaving when a job lands elsewhere. It is churn among the cards that matter least for latency, not a swing between shortages: hash never moves more than 5 points with it (a, c, e, f). The one T5 trip (f, one seed) is the arriving pool's 3090s settling into hybrid.
|
||||
5. **Shard income concentrates on fast cards.** The 5090 class (25% of cards, 57% of hash) proves 53 to 63% of shards; the 3060 class proves 9 to 29%. The acceptance test of spec 7.2 ("the fastest prover wins under 25% of shards", R7) is written per prover, not per class; by weight the 5090 share matches its hash share, so sortition by weight does what F17 asked. Open claiming is where the fast cards win beyond their weight (scenario c, 3060 share 0.09).
|
||||
6. **Scenario f**: a pool with the network's own hash arrives with no weight. It mines, difficulty doubles within the hour, incumbents' income halves, 6% of cards go off (the expensive 3060s and small cards). The newcomers cannot be assigned shards for 30 days and only win open claims, which they do (their 5090 hybrids win the open race). No backlog, no window miss.
|
||||
|
||||
## 4. The worst case
|
||||
|
||||
Scenario b. Hash 82% of pre-event at the trough and 87% at day 30, 10% of cards off, 34% of cards in PROVE mode (up from 15%), every external job delivered, no backlog. It is the worst on the two thresholds that moved (T1 distance and cards off) and it is the one with a mechanism that could get worse: when the coin falls and dollar jobs rise, cards leave the lottery for the job market, and nothing in the protocol pulls them back except the lottery's own difficulty fall. In this run the fall was 14%, far from the 50% line. A deeper price fall or a longer one scales it: the 3060 at median electricity is at break-even at $0.012 x 0.3 and the small cards are below it.
|
||||
|
||||
The worst case that does cross the thresholds is scenario b with chain traffic above the proving fleet's capacity (section 5.2): at 100 shards per block the oldest unproven block reaches 325 s (85 s in the 3-shard sensitivity run with its different seeds; 2 seeds each) and hash troughs at 62% of pre-event; at 300 the backlog is permanent, 17.5% of blocks miss the 60-s window on the worst day, and hash spends 22 hours under the T1 line swinging between 6% and 60% of pre-event. That is the shortage oscillation the reviewer described, and it needs traffic 30 to 100 times the launch assumption to appear. The lever study is run at 100 shards per block, the first traffic where the design's rules are load-bearing.
|
||||
|
||||
## 5. Lever study on scenario b
|
||||
|
||||
Two runs, 2 seeds each, one parameter at a time with the rest at the design values (`sim/economy/levers.md`). The balance score is the mean of three terms: hash at day 30 over pre-event (capped at 1), the worst day's share of blocks within 60 s, and 1 minus the maximum oldest-unproven age over 600 s (floored at 0).
|
||||
|
||||
### 5.1 At launch traffic (3 shards per block): no lever is load-bearing
|
||||
|
||||
| Lever | Value | Hash min / pre | Hash d30 / pre | Age max, s | Worst day within 60 s | Cards off d30 | 3060 shard share | Score |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| pool | 0.10 | 0.81 | 0.84 | 0 | 1.000 | 0.09 | 0.15 | 0.947 |
|
||||
| pool | 0.20 (design) | 0.81 | 0.84 | 0 | 1.000 | 0.13 | 0.18 | 0.947 |
|
||||
| pool | 0.30 | 0.82 | 0.87 | 0 | 1.000 | 0.12 | 0.20 | 0.955 |
|
||||
| pool | 0.40 | 0.77 | 0.78 | 0 | 1.000 | 0.19 | 0.19 | 0.927 |
|
||||
| window | 5 s | 0.75 | 0.78 | 0 | 1.000 | 0.15 | 0.10 | 0.927 |
|
||||
| window | 10 s (design) | 0.81 | 0.84 | 0 | 1.000 | 0.13 | 0.18 | 0.947 |
|
||||
| window | 20 s | 0.84 | 0.85 | 0 | 1.000 | 0.11 | 0.23 | 0.951 |
|
||||
| window | 30 s | 0.84 | 0.85 | 0 | 1.000 | 0.11 | 0.23 | 0.951 |
|
||||
| burn | 0 | 0.80 | 0.83 | 0 | 1.000 | 0.13 | 0.18 | 0.944 |
|
||||
| burn | 0.10 (design) | 0.81 | 0.84 | 0 | 1.000 | 0.13 | 0.18 | 0.947 |
|
||||
| burn | 0.25 | 0.78 | 0.82 | 0 | 1.000 | 0.15 | 0.17 | 0.940 |
|
||||
| burn | 0.50 | 0.82 | 0.84 | 0 | 1.000 | 0.14 | 0.17 | 0.947 |
|
||||
| timeout | 60 s | 0.83 | 0.86 | 0 | 1.000 | 0.25 | 0.06 | 0.954 |
|
||||
| timeout | 120 s | 0.79 | 0.84 | 0 | 1.000 | 0.24 | 0.05 | 0.946 |
|
||||
| timeout | 300 s (default) | 0.81 | 0.84 | 0 | 1.000 | 0.13 | 0.18 | 0.947 |
|
||||
| timeout | 600 s | 0.81 | 0.84 | 0 | 1.000 | 0.13 | 0.18 | 0.947 |
|
||||
|
||||
All four levers move the score by under 3 points because the proving side is never binding at this traffic. The pool share and the burn move money between miners and provers and change little else; a 40% pool takes 19% of cards off (the lottery's 60% can no longer carry the expensive cards). A short claim timeout (60 to 120 s) shuts the 3060 class out of jobs: a quarter of cards go off, hash does not fall because the cards that leave were not hashing.
|
||||
|
||||
### 5.2 Sensitivities at launch traffic
|
||||
|
||||
| Sensitivity | Value | Hash min / pre | Hash d30 / pre | Hours hash under 50% | Age max, s | Worst day within 60 s | Proving share range, points | Score |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| Traffic, shards per block | 3 (default) | 0.81 | 0.84 | 0 | 0 | 1.000 | 7 | 0.947 |
|
||||
| Traffic | 30 | 0.82 | 0.89 | 0 | 0 | 1.000 | | 0.962 |
|
||||
| Traffic | 100 | 0.63 | 0.85 | 0 | 85 | 0.994 | | 0.902 |
|
||||
| Traffic | 300 | 0.06 | 0.75 | 22 | (see note) | 0.825 | | 0.525 |
|
||||
| Observation window | 20 min | 0.80 | 0.86 | 0 | 0 | 1.000 | | 0.952 |
|
||||
| Observation window | 1 h (default) | 0.81 | 0.84 | 0 | 0 | 1.000 | | 0.947 |
|
||||
| Observation window | 3 h | 0.85 | 0.85 | 0 | 0 | 1.000 | | 0.951 |
|
||||
| Observation window | 24 h | 0.81 | 0.82 | 0 | 0 | 1.000 | | 0.939 |
|
||||
|
||||
Traffic is the variable that matters. At 100 shards per block (33x the launch assumption; about 2,000 3060-cards busy full time, 13% of the fleet) a backlog of 85 s appears and hash troughs at 63% of pre-event. At 300 the fleet cannot keep up at all: the backlog is permanent, the backlog rule halves `B_p` repeatedly, blocks miss the 60-s window (82.5% on the worst day), and the hash swings between 6% and 60% of pre-event with 22 hours under the T1 line, because every card that can prove chases the backlog's open claims and then returns when it clears. That is the shortage oscillation the reviewer asked about, and it exists only above the proving fleet's capacity. (The 300 row's age column in `levers.md` is invalid, produced before the age formula was corrected; its other columns stand.) The operators' observation window changes the churn and almost nothing else.
|
||||
|
||||
### 5.3 At 100 shards per block: the window is the lever
|
||||
|
||||
| Lever | Value | Hash min / pre | Hash d30 / pre | Age max, s | Blocks within 60 s | Worst day within 60 s | Cards off d30 | 3060 shard share | Score |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| pool | 0.10 | 0.59 | 0.81 | 168 | 1.000 | 0.994 | 0.05 | 0.22 | 0.841 |
|
||||
| pool | 0.20 (design) | 0.62 | 0.84 | 325 | 1.000 | 0.994 | 0.07 | 0.22 | 0.765 |
|
||||
| pool | 0.30 | 0.64 | 0.86 | 16 | 1.000 | 0.999 | 0.07 | 0.21 | 0.945 |
|
||||
| pool | 0.40 | 0.67 | 0.79 | 0 | 1.000 | 1.000 | 0.10 | 0.21 | 0.931 |
|
||||
| window | 5 s | 0.53 | 0.79 | 565 | 0.993 | 0.929 | 0.04 | 0.11 | 0.592 |
|
||||
| window | 10 s (design) | 0.62 | 0.84 | 325 | 1.000 | 0.994 | 0.07 | 0.22 | 0.765 |
|
||||
| window | 20 s | 0.77 | 0.84 | 0 | 1.000 | 1.000 | 0.08 | 0.24 | 0.948 |
|
||||
| window | 30 s | 0.77 | 0.84 | 0 | 1.000 | 1.000 | 0.08 | 0.24 | 0.948 |
|
||||
| burn | 0 | 0.58 | 0.75 | 325 | 1.000 | 0.994 | 0.11 | 0.19 | 0.733 |
|
||||
| burn | 0.10 (design) | 0.62 | 0.84 | 325 | 1.000 | 0.994 | 0.07 | 0.22 | 0.765 |
|
||||
| burn | 0.25 | 0.63 | 0.83 | 325 | 1.000 | 0.994 | 0.07 | 0.21 | 0.761 |
|
||||
| burn | 0.50 | 0.64 | 0.80 | 325 | 1.000 | 0.994 | 0.10 | 0.21 | 0.750 |
|
||||
| timeout | 60 s | 0.45 | 0.82 | 325 | 1.000 | 0.994 | 0.20 | 0.08 | 0.757 |
|
||||
| timeout | 120 s | 0.75 | 0.89 | 325 | 1.000 | 0.994 | 0.09 | 0.07 | 0.780 |
|
||||
| timeout | 300 s (default) | 0.62 | 0.84 | 325 | 1.000 | 0.994 | 0.07 | 0.22 | 0.765 |
|
||||
| timeout | 600 s | 0.62 | 0.84 | 325 | 1.000 | 0.994 | 0.07 | 0.22 | 0.765 |
|
||||
|
||||
The sortition window is the single parameter that restores balance: 10 s to 20 s takes the worst backlog from 325 s to 0, the hash trough from 62% to 77% of pre-event, the worst day from 99.4% to 100% within 60 s, and the score from 0.765 to 0.948. Nothing else reaches it except a 30% pool (0.945), which buys the same backlog relief by pulling more cards into proving at a cost to the lottery. The burn and the claim timeout do not touch the backlog at all (325 s in every row): they move external money, and external jobs are not what fills the queue.
|
||||
|
||||
Why the window works. With a 10-s window and a 20-s shard, no 3060 and no hybrid 3090 (12 s plus a 5-s swap) can land its assigned proof before the open race starts, and the fastest open claimer (a 5090, 6 s, or a hybrid 5090 at 11 s) beats it. So the assignment is wasted work for the slow classes, they stop answering, every such shard is proved by an open race in which losers waste capacity, and the pool concentrates on the 5090 class. At 20 s or more the assignee finishes first, the duplicated work disappears, the 3060 class's share rises from 0.11 (5 s) to 0.24, and the fleet's whole capacity counts. The window is an exclusivity, not a delay: a proof that lands early is included early, so a longer window costs no latency when the assignee is fast. A 5-s window is the worst value in the table (score 0.592, worst day 92.9%).
|
||||
|
||||
## 6. Proposal
|
||||
|
||||
Proposed, not applied (spec 7.2 item 3 and 7.4; O-5.1 is the parameter's home):
|
||||
|
||||
1. **Set the exclusive window from the shard-time distribution, not at 10 s.** Rule: window = the 90th percentile of the eligible fleet's measured shard time plus one program swap, rounded up to 5 s; at today's targets that is 25 s (20 s on the 12 GB gate card plus 5 s). The phase 4 devnet test (O-5.1) already measures the shard-time distribution across three prover speeds; this makes the window a function of that measurement and keeps the acceptance test (the fastest prover wins under 25% of shards). Nothing in the proof protocol or the records changes; the parameter is a consensus constant either way. In the model the gain is 0 backlog and 15 points of hash at 100 shards per block, and no cost at launch traffic.
|
||||
2. **Tie `B_p` to the live proving fleet rather than a launch calibration.** The design's formula (`docs/design/execution-layer.md` 4.3) sets `B_p` from `cards_proving` read on the testnet. The simulation says the system fails only above the fleet's capacity, and the fleet moves with price (scenario b: a third of cards change mode). The backlog rule halves `B_p` only after 600 s of age, which is 10 minutes of a growing queue. A candidate: `B_p` re-derived every difficulty window from the shards proved within the window in the trailing 24 h, bounded by the halving rule. This is a design question for the execution engineer and is listed here as a finding, not as a rule.
|
||||
3. **Leave the pool share, the burn and the claim timeout as designed.** None of them moves the thresholds in either run. The pool share should not be raised to buy backlog relief (a 40% pool takes 10 to 19% of cards off the lottery); the window does the same job for free. The claim timeout is a market parameter for O-5.6: 120 s keeps jobs on 24 GB cards and the 3060 class on the lottery, which the hash figures favour (0.89 at day 30), and that is the value this study would start the devnet with.
|
||||
|
||||
## 7. Answer to the reviewer
|
||||
|
||||
At launch traffic, both roles stay filled in every scenario: every block is proven within 60 s in every hour of every run, no backlog forms, and hash never falls below 74% of its pre-event level (that floor is scenario d, by construction). The proving side is over-provisioned because the 20% pool is a fixed pot per block that cards compete for, not a capacity they fill, and because the dominant client strategy on 24 GB cards is to mine and answer assignments (half of all cards end there). The system oscillates between shortages only when traffic exceeds what the proving fleet can clear (100 to 300 shards per block in this fleet), and the 10-s sortition window is what wastes the slow half of that fleet first. Raising the window to the slowest eligible shard time plus a swap (25 s at today's targets) removes the backlog and 15 points of the hash loss at 100 shards per block and costs nothing at launch traffic.
|
||||
|
||||
## 8. What the model does not capture, and the three assumptions trusted least
|
||||
|
||||
1. **Shard time and the hybrid swap (ledger P1, M11).** The 20-s 3060 shard is the phase 2 target, unmeasured; the 5-s program swap on the prover side is a guess. Both set who wins the window race and the under-20-s share. If the swap is 30 s, HYBRID loses the race to PROVE cards and the mode split changes; the 60-s result survives unless shard times exceed 40 s.
|
||||
2. **Operator behaviour.** Hourly observation, 12-minute decisions, 1-hour dwell, 5 to 25% hysteresis, myopic about weight. The sensitivity table shows what a 20-minute window does to churn and what 24 h does. Real clients (NiceHash-style switchers) sit between, approximate.
|
||||
3. **Traffic and the price process.** Three shards per block makes proving a subsidy race; the traffic sensitivity shows where it becomes a capacity question. The price is exogenous with no feedback from burns, emission or hash, and the 70% fall is imposed, not caused.
|
||||
|
||||
Also missing: the DAG (no reds, no parallel blocks), network latency, the pool protocol, reputation, the finality rule's effect on who is eligible (the 100-block dust line removes solo small operators unless pooled), bonds on external jobs (modelled as a class filter only), and the launch ramp (the run starts with emission at 100%).
|
||||
|
|
@ -378,3 +378,11 @@ Findings (not consensus failures; filed for the ledger):
|
|||
- F-exec-B (medium, griefing): an over-pgas-budget transaction is executed natively in full before it is skipped, and because it is skipped it pays no fee. A transaction whose own pgas exceeds B_p (for example one large modexp, or the 9,000-iter loop above at 30.96 M pgas) is included, executed (10.85 ms of real work here, more for a bigger input), then dropped with `BlockProvingBudget` and charged nothing (`igneum/exec/src/executor.rs`: the skip happens after `inspect_one_tx` runs and before any fee is taken). Every node re-executes it on every inclusion for free, and because the nonce never advances it also head-of-line-blocks that sender's higher nonces (seen here: the 14,000 and 20,000 loops were never includable behind the stuck 9,000). The funds check at admission does not bound pgas (pgas is not known without execution), so a modestly funded account can force repeated free computation network-wide. Fix options: charge the intrinsic plus consumed pgas on a budget skip, cap single-transaction pgas at admission via `eth_estimateGas`-style simulation, or drop a sender's queue on a `BlockProvingBudget` skip rather than retrying.
|
||||
|
||||
Not covered here (out of scope for this pass, and because the proving layer is not implemented on this branch): proof records, the native-execution veto, sortition, and the finality lock (`proven`/`locked` are always false on devnet v3, so only `executed` was exercised). These need the proving layer and the finality merge (design 10.4) before they can be attacked.
|
||||
|
||||
## 4 October 2026, sim/economy: mining versus proving under stress, agent-based (economist; model, not hardware)
|
||||
|
||||
Machine: Apple M5 Max, shared (load 9 to 25), single process at nice 19, about 28 minutes of compute in total. `sim/economy/sim.py`, Python 3.10.10, numpy 2.2.6; 1,000 operators, 30 days, 180-s ticks, 13 to 25 s per run. Inputs: RTX 5090 229 MH/s (measured, this log); every other number approximate (`docs/analysis/economy-2026-10-04.md`, assumptions table).
|
||||
Six scenarios x 5 seeds (`sim/economy/results.md`): no backlog, no window miss, no hash under 50% of pre-event in any run. Hash troughs: a 0.95, b (price down 70%, external x10) 0.82, c 0.97, d (20% operator leaves) 0.75, e (30% withholder) 0.98, f (2x pool arrives) 0.95 of pre-event; day 30: 1.00 / 0.87 / 1.00 / 0.80 / 1.00 / 1.92. Blocks proven within 60 s: 1.00 in every hour; within 20 s: 0.14 to 0.41. Cards in hybrid mode (mine, answer own assignments) at day 30: 42 to 54%; cards off: 1% (a, c, e) to 10% (b). Profit $ per card-day, baseline: 5090 6.37, 3090 1.66, 3060 0.78, small 0.39; shard share 5090 0.59, 3090 0.26, 3060 0.16. Proving-share 10-90 range over the last 10 days 3 to 9 points (one seed of f at 10.1).
|
||||
Sensitivities on b (2 seeds, `sim/economy/levers.md`): traffic 3 / 30 / 100 / 300 shards per block gives hash trough 0.81 / 0.82 / 0.63 / 0.06, oldest unproven age 0 / 0 / 85 / permanent, worst day within 60 s 1.000 / 1.000 / 0.994 / 0.825, hours under 50% hash 0 / 0 / 0 / 22. Observation window 20 min to 24 h: score 0.939 to 0.952, churn only.
|
||||
Lever study on b at 100 shards per block (2 seeds): window 5 / 10 / 20 / 30 s gives age max 565 / 325 / 0 / 0 s, hash trough 0.53 / 0.62 / 0.77 / 0.77, score 0.592 / 0.765 / 0.948 / 0.948; pool 0.1 / 0.2 / 0.3 / 0.4 gives age 168 / 325 / 16 / 0 and cards off 0.05 / 0.07 / 0.07 / 0.10; burn 0 to 0.5 and claim timeout 60 to 600 s leave the age at 325 s in every row. Proposal (not applied): window = p90 shard time plus one swap, 25 s at today's targets (O-5.1); `B_p` tied to the live proving fleet rather than a launch calibration.
|
||||
Not done: DAG and network latency, pool protocol, bonds on jobs beyond a class filter, price feedback from burns, the launch ramp; the age column of the 300-shard sensitivity row predates the age-formula fix.
|
||||
|
|
|
|||
49
sim/economy/README.md
Normal file
49
sim/economy/README.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# sim/economy
|
||||
|
||||
Agent-based economy simulator for Igneum (4 October 2026): 1,000 GPU operators choosing between
|
||||
mining and proving, each with its own client and its own profit. Model, scenarios, lever study and
|
||||
results behind `docs/analysis/economy-2026-10-04.md`. Nothing here is a measurement of hardware;
|
||||
the one measured input is the RTX 5090 hash rate (229 MH/s, `docs/bench-log.md`).
|
||||
|
||||
## Files
|
||||
|
||||
| File | What |
|
||||
|---|---|
|
||||
| `sim.py` | the simulator: population, per-tick market (lottery, sortition, open claiming, external jobs, backlog), per-operator decisions, metrics, scenario and lever tables as markdown |
|
||||
| `results.md` | raw output of the six-scenario run (5 seeds) |
|
||||
| `levers.md` | raw output of the lever study on the worst scenario at launch traffic and at 100 shards per block, with the traffic and observation-window sensitivities |
|
||||
|
||||
## Model in one paragraph
|
||||
|
||||
Ticks of 180 s over 30 days. Each operator holds cards of four classes (5090, 3090, 3060, small) and
|
||||
sets a mode per class: MINE, PROVE (proving-ready, answers assignments and races open claims), HYBRID
|
||||
(mines, swaps program to answer assignments and open claims it can win; 24 GB cards only) or OFF.
|
||||
Per tick: hash rate, a difficulty controller with the spec 2.3 clamps, Poisson blocks, blocks
|
||||
attributed by hash (multinomial), internal shards (Poisson, 3 per block at launch traffic), a 30-day
|
||||
weight per operator (its mined blocks), sortition of 8 assignees by weight with a 10-s exclusive
|
||||
window, open claiming in order of latency (fastest responder wins, race losers waste work), a backlog
|
||||
queue, external jobs (Poisson, dollars, same sortition then open, claim timeout limits which classes
|
||||
may take a job, undeliverable jobs expire), the pool paid per block divided by shards, 90/10 on
|
||||
external jobs, electricity by card and mode, a GBM coin price. Every 12 minutes (own phase) an
|
||||
operator compares expected profit per card-hour by mode from what it observed over the last hour and
|
||||
switches when the gain beats its own hysteresis (5 to 25%) and it has not switched in the last hour.
|
||||
|
||||
## Runs
|
||||
|
||||
python3 sim.py six scenarios, 5 seeds (about 10 minutes)
|
||||
python3 sim.py --scenarios b --seeds 1 one scenario
|
||||
python3 sim.py --levers b --seeds 2 lever study on scenario b
|
||||
python3 sim.py --set ema_ticks=80 --scenarios a,b --seeds 2 behavioural sensitivity
|
||||
python3 sim.py --dump b --seeds 1 hourly CSV of one run
|
||||
|
||||
Requirements: Python 3, numpy (3.10.10, numpy 2.2.6 on 3 October 2026). One run of 30 days takes
|
||||
about 20 s on the M5 Max at nice 19.
|
||||
|
||||
## Limits
|
||||
|
||||
No DAG (blocks are attributed by hash share, no reds); the within-tick market is closed-form, so
|
||||
shard-level luck is averaged over each tick; the difficulty controller is a clamped tracker of the
|
||||
spec rule, not the rule itself; the price process is exogenous (burns do not move it); operators
|
||||
are myopic (they do not value the sortition weight that mining builds); no pool protocol (small
|
||||
operators are treated as pooled for eligibility); no reputation. The assumptions table in the
|
||||
analysis document lists every number and its label.
|
||||
61
sim/economy/levers.md
Normal file
61
sim/economy/levers.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Lever study and sensitivities, scenario b
|
||||
|
||||
Run 1: design traffic (3 shards per block), protocol levers plus the traffic and observation-window sensitivities. Note: the age column of the 300-shard row was produced before the backlog-age formula was corrected (it diverged once the backlog rule throttled arrivals); the hash, window and mode columns of that row stand. Run 2: the protocol levers at 100 shards per block, after the correction.
|
||||
|
||||
## Run 1, 3 shards per block
|
||||
|
||||
Lever study on scenario b (external pays 10x from day 7, coin price falls 70% over days 7 to 14), seeds [1, 2]. One parameter at a time, the rest at the design values.
|
||||
|
||||
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.
|
||||
|
||||
| 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| pool | 0.1 | 0.81 | 0.84 | 0 | 0 | 1.000 | 1.000 | 0.315 | 0.094 | 1.00 | 0.15 | 0.947 |
|
||||
| pool | 0.2 (design) | 0.81 | 0.84 | 0 | 0 | 1.000 | 1.000 | 0.374 | 0.127 | 1.00 | 0.18 | 0.947 |
|
||||
| pool | 0.3 | 0.82 | 0.87 | 0 | 0 | 1.000 | 1.000 | 0.403 | 0.117 | 1.00 | 0.20 | 0.955 |
|
||||
| pool | 0.4 | 0.77 | 0.78 | 0 | 0 | 1.000 | 1.000 | 0.475 | 0.188 | 1.00 | 0.19 | 0.927 |
|
||||
| window | 5.0 | 0.75 | 0.78 | 0 | 0 | 1.000 | 1.000 | 0.366 | 0.151 | 1.00 | 0.10 | 0.927 |
|
||||
| window | 10.0 (design) | 0.81 | 0.84 | 0 | 0 | 1.000 | 1.000 | 0.374 | 0.127 | 1.00 | 0.18 | 0.947 |
|
||||
| window | 20.0 | 0.84 | 0.85 | 0 | 0 | 1.000 | 1.000 | 0.355 | 0.113 | 1.00 | 0.23 | 0.951 |
|
||||
| window | 30.0 | 0.84 | 0.85 | 0 | 0 | 1.000 | 1.000 | 0.355 | 0.113 | 1.00 | 0.23 | 0.951 |
|
||||
| burn | 0.0 | 0.80 | 0.83 | 0 | 0 | 1.000 | 1.000 | 0.374 | 0.134 | 1.00 | 0.18 | 0.944 |
|
||||
| burn | 0.1 (design) | 0.81 | 0.84 | 0 | 0 | 1.000 | 1.000 | 0.374 | 0.127 | 1.00 | 0.18 | 0.947 |
|
||||
| burn | 0.25 | 0.78 | 0.82 | 0 | 0 | 1.000 | 1.000 | 0.387 | 0.145 | 1.00 | 0.17 | 0.940 |
|
||||
| burn | 0.5 | 0.82 | 0.84 | 0 | 0 | 1.000 | 1.000 | 0.342 | 0.135 | 1.00 | 0.17 | 0.947 |
|
||||
| timeout | 60.0 | 0.83 | 0.86 | 0 | 0 | 1.000 | 1.000 | 0.133 | 0.247 | 1.00 | 0.06 | 0.954 |
|
||||
| timeout | 120.0 | 0.79 | 0.84 | 0 | 0 | 1.000 | 1.000 | 0.191 | 0.235 | 1.00 | 0.05 | 0.946 |
|
||||
| timeout | 300.0 (design) | 0.81 | 0.84 | 0 | 0 | 1.000 | 1.000 | 0.374 | 0.127 | 1.00 | 0.18 | 0.947 |
|
||||
| timeout | 600.0 | 0.81 | 0.84 | 0 | 0 | 1.000 | 1.000 | 0.374 | 0.127 | 1.00 | 0.18 | 0.947 |
|
||||
| shards_per_block | 3.0 (default) | 0.81 | 0.84 | 0 | 0 | 1.000 | 1.000 | 0.374 | 0.127 | 1.00 | 0.18 | 0.947 |
|
||||
| shards_per_block | 30.0 | 0.82 | 0.89 | 0 | 0 | 1.000 | 1.000 | 0.305 | 0.069 | 1.00 | 0.19 | 0.962 |
|
||||
| shards_per_block | 100.0 | 0.63 | 0.85 | 0 | 85 | 1.000 | 0.994 | 0.293 | 0.054 | 1.00 | 0.23 | 0.902 |
|
||||
| shards_per_block | 300.0 | 0.06 | 0.75 | 22 | 209232311263960 | 0.948 | 0.825 | 0.289 | 0.049 | 1.00 | 0.28 | 0.525 |
|
||||
| ema_ticks | 7.0 | 0.80 | 0.86 | 0 | 0 | 1.000 | 1.000 | 0.306 | 0.145 | 1.00 | 0.16 | 0.952 |
|
||||
| ema_ticks | 20.0 (default) | 0.81 | 0.84 | 0 | 0 | 1.000 | 1.000 | 0.374 | 0.127 | 1.00 | 0.18 | 0.947 |
|
||||
| ema_ticks | 60.0 | 0.85 | 0.85 | 0 | 0 | 1.000 | 1.000 | 0.376 | 0.106 | 1.00 | 0.18 | 0.951 |
|
||||
| ema_ticks | 480.0 | 0.81 | 0.82 | 0 | 0 | 1.000 | 1.000 | 0.408 | 0.130 | 1.00 | 0.17 | 0.939 |
|
||||
|
||||
## Run 2, 100 shards per block
|
||||
|
||||
Lever study on scenario b (external pays 10x from day 7, coin price falls 70% over days 7 to 14), seeds [1, 2], traffic 100 shards per block. One parameter at a time, the rest at the design values.
|
||||
|
||||
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.
|
||||
|
||||
| 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| pool | 0.1 | 0.59 | 0.81 | 0 | 168 | 1.000 | 0.994 | 0.271 | 0.046 | 1.00 | 0.22 | 0.841 |
|
||||
| pool | 0.2 (design) | 0.62 | 0.84 | 0 | 325 | 1.000 | 0.994 | 0.301 | 0.071 | 1.00 | 0.22 | 0.765 |
|
||||
| pool | 0.3 | 0.64 | 0.86 | 0 | 16 | 1.000 | 0.999 | 0.330 | 0.073 | 1.00 | 0.21 | 0.945 |
|
||||
| pool | 0.4 | 0.67 | 0.79 | 0 | 0 | 1.000 | 1.000 | 0.441 | 0.097 | 1.00 | 0.21 | 0.931 |
|
||||
| window | 5.0 | 0.53 | 0.79 | 0 | 565 | 0.993 | 0.929 | 0.291 | 0.040 | 1.00 | 0.11 | 0.592 |
|
||||
| window | 10.0 (design) | 0.62 | 0.84 | 0 | 325 | 1.000 | 0.994 | 0.301 | 0.071 | 1.00 | 0.22 | 0.765 |
|
||||
| window | 20.0 | 0.77 | 0.84 | 0 | 0 | 1.000 | 1.000 | 0.315 | 0.078 | 1.00 | 0.24 | 0.948 |
|
||||
| window | 30.0 | 0.77 | 0.84 | 0 | 0 | 1.000 | 1.000 | 0.315 | 0.078 | 1.00 | 0.24 | 0.948 |
|
||||
| burn | 0.0 | 0.58 | 0.75 | 0 | 325 | 1.000 | 0.994 | 0.361 | 0.110 | 1.00 | 0.19 | 0.733 |
|
||||
| burn | 0.1 (design) | 0.62 | 0.84 | 0 | 325 | 1.000 | 0.994 | 0.301 | 0.071 | 1.00 | 0.22 | 0.765 |
|
||||
| burn | 0.25 | 0.63 | 0.83 | 0 | 325 | 1.000 | 0.994 | 0.291 | 0.065 | 1.00 | 0.21 | 0.761 |
|
||||
| burn | 0.5 | 0.64 | 0.80 | 0 | 325 | 1.000 | 0.994 | 0.310 | 0.100 | 1.00 | 0.21 | 0.750 |
|
||||
| timeout | 60.0 | 0.45 | 0.82 | 0 | 325 | 1.000 | 0.994 | 0.095 | 0.199 | 1.00 | 0.08 | 0.757 |
|
||||
| timeout | 120.0 | 0.75 | 0.89 | 0 | 325 | 1.000 | 0.994 | 0.099 | 0.091 | 1.00 | 0.07 | 0.780 |
|
||||
| timeout | 300.0 (design) | 0.62 | 0.84 | 0 | 325 | 1.000 | 0.994 | 0.301 | 0.071 | 1.00 | 0.22 | 0.765 |
|
||||
| timeout | 600.0 | 0.62 | 0.84 | 0 | 325 | 1.000 | 0.994 | 0.301 | 0.071 | 1.00 | 0.22 | 0.765 |
|
||||
61
sim/economy/results.md
Normal file
61
sim/economy/results.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Igneum economy simulation, 1000 operators, 30 days, seeds [1, 2, 3, 4, 5], tick 180 s
|
||||
|
||||
Parameters: tick=180.0, days=30, n_ops=1000, emission=31.688, pool=0.2, window=10.0, assignees=8, burn=0.1, timeout=300.0, shards_per_block=3.0, job_work=10.0, ext_usd_day=2000.0, ext_mult=1.0, price0=0.012, vol_day=0.05, swap=5.0, aggregation=4.0, waste=0.5, hyst_lo=0.05, hyst_hi=0.25, dwell_ticks=20, decide_every=4, ema_ticks=20.0, farm_share=0.2, backlog_rule=600.0
|
||||
|
||||
- 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)
|
||||
|
||||
| Metric | a | a min | a max | b | b min | b max | c | c min | c max | d | d min | d max | e | e min | e max | f | f min | f max |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| hash mining share (mean) | 0.93 | 0.92 | 0.94 | 0.85 | 0.84 | 0.87 | 0.96 | 0.95 | 0.96 | 0.92 | 0.92 | 0.93 | 0.91 | 0.90 | 0.91 | 0.93 | 0.91 | 0.93 |
|
||||
| cards proving, day 1 | 0.15 | 0.14 | 0.15 | 0.15 | 0.14 | 0.15 | 0.09 | 0.08 | 0.10 | 0.15 | 0.14 | 0.15 | 0.21 | 0.21 | 0.22 | 0.16 | 0.15 | 0.17 |
|
||||
| cards proving, day 10 | 0.15 | 0.14 | 0.17 | 0.27 | 0.25 | 0.28 | 0.08 | 0.08 | 0.09 | 0.15 | 0.14 | 0.17 | 0.22 | 0.21 | 0.23 | 0.15 | 0.14 | 0.16 |
|
||||
| cards proving, day 20 | 0.16 | 0.13 | 0.19 | 0.36 | 0.32 | 0.41 | 0.08 | 0.06 | 0.09 | 0.12 | 0.09 | 0.15 | 0.21 | 0.20 | 0.22 | 0.14 | 0.12 | 0.15 |
|
||||
| cards proving, day 30 | 0.15 | 0.11 | 0.20 | 0.34 | 0.29 | 0.40 | 0.09 | 0.07 | 0.11 | 0.13 | 0.11 | 0.15 | 0.21 | 0.19 | 0.23 | 0.17 | 0.14 | 0.22 |
|
||||
| cards hybrid, day 30 | 0.54 | 0.53 | 0.55 | 0.45 | 0.40 | 0.51 | 0.54 | 0.53 | 0.55 | 0.45 | 0.45 | 0.46 | 0.42 | 0.41 | 0.43 | 0.50 | 0.44 | 0.52 |
|
||||
| cards off, day 30 | 0.01 | 0.00 | 0.04 | 0.10 | 0.06 | 0.15 | 0.01 | 0.00 | 0.02 | 0.09 | 0.09 | 0.09 | 0.01 | 0.00 | 0.03 | 0.06 | 0.01 | 0.17 |
|
||||
| hash min / pre-event | 0.95 | 0.93 | 0.95 | 0.82 | 0.81 | 0.85 | 0.97 | 0.96 | 0.97 | 0.75 | 0.74 | 0.75 | 0.98 | 0.98 | 0.99 | 0.95 | 0.95 | 0.96 |
|
||||
| hash day 30 / pre-event | 1.00 | 0.97 | 1.02 | 0.87 | 0.82 | 0.91 | 1.00 | 0.98 | 1.01 | 0.80 | 0.78 | 0.81 | 1.00 | 0.99 | 1.01 | 1.92 | 1.77 | 2.00 |
|
||||
| hours hash under 50% | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 |
|
||||
| backlog max, shards | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 |
|
||||
| oldest unproven age max, s | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 |
|
||||
| age max day 10, s | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 |
|
||||
| age max day 20, s | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 |
|
||||
| age max day 30, s | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 |
|
||||
| blocks proven within 60 s | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 |
|
||||
| worst day within 60 s | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 |
|
||||
| blocks proven within 20 s | 0.26 | 0.25 | 0.27 | 0.41 | 0.26 | 0.64 | 0.29 | 0.28 | 0.30 | 0.21 | 0.20 | 0.22 | 0.14 | 0.13 | 0.14 | 0.27 | 0.23 | 0.40 |
|
||||
| blocks/s mean | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 |
|
||||
| blocks/s hourly max | 1.05 | 1.05 | 1.06 | 1.05 | 1.05 | 1.06 | 1.05 | 1.04 | 1.06 | 1.05 | 1.04 | 1.06 | 1.05 | 1.04 | 1.05 | 1.05 | 1.05 | 1.06 |
|
||||
| blocks/s hourly min | 0.95 | 0.94 | 0.95 | 0.95 | 0.94 | 0.95 | 0.95 | 0.94 | 0.95 | 0.95 | 0.95 | 0.95 | 0.95 | 0.94 | 0.96 | 0.95 | 0.94 | 0.95 |
|
||||
| difficulty end / start | 1.00 | 0.96 | 1.04 | 0.86 | 0.80 | 0.94 | 1.00 | 0.98 | 1.01 | 0.80 | 0.77 | 0.85 | 1.00 | 0.98 | 1.02 | 1.91 | 1.76 | 1.98 |
|
||||
| external jobs delivered | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | nan | nan | nan | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 |
|
||||
| price at day 30, $ | 0.01 | 0.01 | 0.02 | 0.00 | 0.00 | 0.01 | 0.01 | 0.01 | 0.01 | 0.02 | 0.01 | 0.02 | 0.01 | 0.01 | 0.02 | 0.01 | 0.01 | 0.01 |
|
||||
| proving-share crossings per day | 13.20 | 11.10 | 14.30 | 6.92 | 6.10 | 8.30 | 15.18 | 11.10 | 17.50 | 13.94 | 12.60 | 15.20 | 10.56 | 9.50 | 11.80 | 11.92 | 10.70 | 15.70 |
|
||||
| proving-share 10-90 pct range, points | 8.46 | 7.95 | 9.34 | 7.36 | 5.93 | 9.93 | 2.79 | 1.87 | 3.89 | 8.45 | 7.81 | 9.43 | 3.83 | 3.23 | 4.52 | 5.72 | 3.57 | 10.09 |
|
||||
| mode switches (operator x class) | 245,632 | 221,853 | 263,797 | 149,307 | 136,699 | 159,157 | 202,169 | 189,878 | 211,638 | 248,420 | 243,171 | 262,933 | 124,643 | 113,075 | 132,562 | 248,936 | 159,175 | 468,404 |
|
||||
|
||||
| Flag | a | b | c | d | e | f |
|
||||
|---|---|---|---|---|---|---|
|
||||
| miner shortage (hash under 50% of pre-event for 1 h or more) | 0/5 | 0/5 | 0/5 | 0/5 | 0/5 | 0/5 |
|
||||
| backlog over 600 s (design's backlog-rule trigger) | 0/5 | 0/5 | 0/5 | 0/5 | 0/5 | 0/5 |
|
||||
| growing backlog (positive 10-day trend and over 60 s at day 30) | 0/5 | 0/5 | 0/5 | 0/5 | 0/5 | 0/5 |
|
||||
| window miss (a day under 90% of blocks within 60 s) | 0/5 | 0/5 | 0/5 | 0/5 | 0/5 | 0/5 |
|
||||
| oscillation (proving-share 10-90 range over 10 points in the last 10 days) | 0/5 | 0/5 | 0/5 | 0/5 | 0/5 | 1/5 |
|
||||
|
||||
Operator profit by card class, $ per card-day (mean over seeds, all modes including off):
|
||||
|
||||
| Scenario | 5090 | 3090 | 3060 | small | shard share 5090 | shard share 3090 | shard share 3060 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| a | 6.373 | 1.663 | 0.780 | 0.394 | 0.59 | 0.26 | 0.16 |
|
||||
| b | 5.170 | 1.819 | 1.355 | 0.146 | 0.63 | 0.19 | 0.18 |
|
||||
| c | 6.115 | 1.569 | 0.762 | 0.388 | 0.62 | 0.29 | 0.09 |
|
||||
| d | 6.480 | 2.338 | 1.064 | 0.572 | 0.53 | 0.31 | 0.16 |
|
||||
| e | 5.081 | 1.534 | 0.853 | 0.311 | 0.43 | 0.28 | 0.29 |
|
||||
| f | 3.350 | 0.685 | 0.431 | 0.149 | 0.56 | 0.25 | 0.19 |
|
||||
|
||||
<!-- total 406 s -->
|
||||
745
sim/economy/sim.py
Normal file
745
sim/economy/sim.py
Normal file
|
|
@ -0,0 +1,745 @@
|
|||
#!/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()
|
||||
Loading…
Reference in a new issue