533 lines
28 KiB
Python
533 lines
28 KiB
Python
#!/usr/bin/env python3
|
|
"""Potatium: dynamic worker activation, three PoP windows, treasury circulation.
|
|
|
|
Pure Monte-Carlo RESEARCH. No on-chain code, cryptographic PoW, personal keys,
|
|
commercial price assumptions, or final protocol parameters.
|
|
Uses only Python standard library; floats are NOT acceptable for consensus.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import random
|
|
import statistics
|
|
import zipfile
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
F1_F2_F3 = {"balanced": (.55, .30, .20), "cautious": (.50, .30, .20), "original": (.70, .70, .70)}
|
|
WINDOW_SCALES = {"low": .01, "mild": .1, "original": 1.}
|
|
MAX_WORKERS = 10 # Research range, NOT a protocol cap
|
|
HISTORY_DAYS = 90
|
|
EPS = 1e-9
|
|
|
|
|
|
def progressive_fee(rewards_90d: float) -> float:
|
|
if not math.isfinite(rewards_90d) or rewards_90d < 0:
|
|
raise ValueError("invalid rewards_90d")
|
|
return (min(rewards_90d, 10) * .00005
|
|
+ min(max(rewards_90d - 10, 0), 90) * .0002
|
|
+ max(rewards_90d - 100, 0) * .0005)
|
|
|
|
|
|
def window_fee(stage: int, rewards_90d: float, model: str = "low") -> float:
|
|
if stage not in (2, 3) or model not in WINDOW_SCALES:
|
|
raise ValueError("invalid PoP stage or fee model")
|
|
p = progressive_fee(rewards_90d) * WINDOW_SCALES[model]
|
|
return (.001 + p) if stage == 2 else (.003 + 2 * p)
|
|
|
|
|
|
def worker_fee(count: int, scale: float) -> float:
|
|
if not isinstance(count, int) or not 0 <= count <= MAX_WORKERS or not math.isfinite(scale) or scale < 0:
|
|
raise ValueError("invalid worker count or fee scale")
|
|
base = min(count, 3) * .001 + min(max(count - 3, 0), 4) * .003 + max(count - 7, 0) * .005
|
|
return scale * base
|
|
|
|
|
|
def gini(balances: list[float]) -> float:
|
|
if any(not math.isfinite(x) or x < -EPS for x in balances):
|
|
raise ValueError("invalid balance")
|
|
sorted_bal = sorted(max(0., b) for b in balances)
|
|
total = sum(sorted_bal)
|
|
if total <= 0:
|
|
return 0.
|
|
return (2 * sum((i+1)*v for i,v in enumerate(sorted_bal)) / (len(sorted_bal)*total)
|
|
- (len(sorted_bal)+1)/len(sorted_bal))
|
|
|
|
|
|
@dataclass
|
|
class Operator:
|
|
id: int
|
|
capacity: int
|
|
join_day: int
|
|
balance: float
|
|
recent: float = 0.
|
|
earned: float = 0.
|
|
worker_paid: float = 0.
|
|
window_paid: float = 0.
|
|
rebated: float = 0.
|
|
wins: int = 0
|
|
tickets: int = 0
|
|
activation_total: int = 0
|
|
activation_rounds: int = 0
|
|
paid_f2: int = 0
|
|
paid_f3: int = 0
|
|
refused_budget: int = 0
|
|
history: list[float] = field(default_factory=lambda: [0.]*HISTORY_DAYS)
|
|
|
|
def expire(self, day: int) -> None:
|
|
idx = day % HISTORY_DAYS
|
|
self.recent -= self.history[idx]
|
|
self.history[idx] = 0.
|
|
if abs(self.recent) < EPS:
|
|
self.recent = 0.
|
|
|
|
def reward(self, day: int, amount: float) -> None:
|
|
self.earned += amount
|
|
self.balance += amount
|
|
self.wins += 1
|
|
self.history[day % HISTORY_DAYS] += amount
|
|
self.recent += amount
|
|
|
|
@property
|
|
def fees(self) -> float:
|
|
return self.worker_paid + self.window_paid
|
|
|
|
|
|
def marginal_ticket_gain(own: int, rival_est: float, reward: float) -> float:
|
|
if own < 0 or rival_est < 0:
|
|
raise ValueError("ticket forecast invalid")
|
|
if own == 0:
|
|
return reward / (1 + rival_est)
|
|
total = own + rival_est
|
|
return reward * rival_est / (total * (total + 1))
|
|
|
|
|
|
def choose_worker_count(*, balance: float, capacity: int, mode: str, scale: float,
|
|
rivals_estimated_tickets: float, f1_chance: float,
|
|
reward: float, worker_choice_margin: float = 1.0) -> int:
|
|
"""Heuristic worker choice BEFORE current mining proof/challenge.
|
|
|
|
'fixed' requests all capacity (budget may reduce it).
|
|
'dynamic' maximizes [estimated ticket share * reward - worker fee]
|
|
against all affordable capacities. Only F1 chance informs forecast, hence
|
|
a deliberately imperfect/myopic policy; it cannot observe live rival proofs.
|
|
"""
|
|
if mode not in ("fixed", "dynamic") or capacity < 0 or capacity > MAX_WORKERS:
|
|
raise ValueError("invalid worker choice")
|
|
if balance < -EPS or not 0 < f1_chance <= 1 or reward <= 0 or worker_choice_margin <= 0:
|
|
raise ValueError("invalid worker choice parameter")
|
|
affordable = [n for n in range(capacity+1) if worker_fee(n, scale) <= balance+EPS]
|
|
if mode == "fixed":
|
|
return affordable[-1]
|
|
def net(n: int) -> float:
|
|
own = (n + 1) * f1_chance
|
|
return worker_choice_margin * reward * own / (own + max(rivals_estimated_tickets, EPS)) - worker_fee(n, scale)
|
|
# tie-break toward fewer workers
|
|
return max(affordable, key=lambda n: (net(n), -n))
|
|
|
|
|
|
def should_buy_window(*, policy: str, op_balance: float, charge: float, stage: int,
|
|
own_tickets: int, rival_estimate: float, reward: float,
|
|
probabilities: tuple[float,float,float], future_charge: float = 0.) -> bool:
|
|
if op_balance + EPS < charge or policy == "free":
|
|
return False
|
|
if policy == "always":
|
|
return True
|
|
if policy != "rational":
|
|
raise ValueError("unknown window policy")
|
|
value = marginal_ticket_gain(own_tickets, rival_estimate, reward)
|
|
if stage == 2:
|
|
future = max(0., probabilities[2] * value - future_charge) if op_balance + EPS >= charge + future_charge else 0.
|
|
expected = probabilities[1] * value + (1 - probabilities[1]) * future
|
|
elif stage == 3:
|
|
expected = probabilities[2] * value
|
|
else:
|
|
raise ValueError("invalid stage")
|
|
return expected > charge + 1e-12
|
|
|
|
|
|
def normalize_capacities(operator_count: int, distribution: list[int], newcomers: int) -> list[int]:
|
|
if not distribution or any(not isinstance(v, int) or v < 0 or v > MAX_WORKERS for v in distribution):
|
|
raise ValueError("worker distribution requires integers 0..10")
|
|
return [distribution[i % len(distribution)] for i in range(operator_count + newcomers)]
|
|
|
|
|
|
def simulate(*, operators: int = 20, newcomers: int = 5, join_day: int = 90,
|
|
capacities: tuple[int,...] = (0,1,3,5,8,10),
|
|
days: int = 180, rounds_per_day: int = 72, seed: int = 20261025,
|
|
worker_mode: str = "dynamic", worker_scale: float = 3.,
|
|
fund_mode: str = "hold", payout_share: float = .25, payout_interval: int = 30,
|
|
window_policy: str = "rational", window_model: str = "low",
|
|
profile: str = "balanced", reward: float = 1.,
|
|
initial_balance: float = 0.) -> dict:
|
|
"""Run a full PoP lottery with conservation-checked on-chain-equivalent fund ledger.
|
|
|
|
Genesis allocations initial_balance are allocated to *all* operators,
|
|
including future joiners, from day 0; no external minting upon joining.
|
|
Fund refunds use equal active-operator allocation, not an endorsed policy.
|
|
"""
|
|
if not (2 <= operators <= 300 and 0 <= newcomers <= 300 and operators + newcomers <= 300
|
|
and days >= 1 and rounds_per_day >= 1 and 0 <= join_day and (newcomers == 0 or join_day < days)
|
|
and isinstance(seed, int) and math.isfinite(initial_balance) and initial_balance >= 0
|
|
and math.isfinite(reward) and reward > 0
|
|
and worker_mode in ("fixed", "dynamic") and fund_mode in ("hold", "redistribute")
|
|
and window_policy in ("free", "rational", "always")
|
|
and profile in F1_F2_F3 and window_model in WINDOW_SCALES
|
|
and 0 <= payout_share <= 1 and payout_interval >= 1):
|
|
raise ValueError("invalid simulation parameters")
|
|
# Check every capacity and scale early.
|
|
caps = normalize_capacities(operators, list(capacities), newcomers)
|
|
worker_fee(max(caps), worker_scale)
|
|
probabilities = F1_F2_F3[profile]
|
|
rng = random.Random(seed)
|
|
persons = [Operator(i, caps[i], 0 if i < operators else join_day, initial_balance)
|
|
for i in range(operators + newcomers)]
|
|
fund = 0.
|
|
fund_in = fund_out = minted = 0.
|
|
no_ticket_rounds = 0
|
|
total_tickets = 0
|
|
daily = []
|
|
# Prior-round info only. Default forecast: all rivals' hubs qualify at F1.
|
|
last_rival_tickets = {op.id: (operators-1) * probabilities[0] for op in persons}
|
|
for day in range(days):
|
|
living = [op for op in persons if op.join_day <= day]
|
|
for op in living:
|
|
op.expire(day)
|
|
day_wins = 0
|
|
day_activations = 0
|
|
for _ in range(rounds_per_day):
|
|
nodes: list[Operator] = []
|
|
current_counts: dict[int,int] = {}
|
|
# All operator decisions occur before F1 challenge, no peeking at outcomes.
|
|
for op in living:
|
|
chosen = choose_worker_count(
|
|
balance=op.balance, capacity=op.capacity, mode=worker_mode,
|
|
scale=worker_scale, rivals_estimated_tickets=last_rival_tickets[op.id],
|
|
f1_chance=probabilities[0], reward=reward)
|
|
if chosen < op.capacity:
|
|
op.refused_budget += int(worker_fee(chosen+1, worker_scale) > op.balance + EPS)
|
|
fee = worker_fee(chosen, worker_scale)
|
|
if fee > 0:
|
|
op.balance -= fee
|
|
op.worker_paid += fee
|
|
fund += fee
|
|
fund_in += fee
|
|
op.activation_total += chosen
|
|
op.activation_rounds += 1
|
|
day_activations += chosen
|
|
current_counts[op.id] = chosen + 1
|
|
nodes.extend([op]*(chosen + 1))
|
|
# Each node ID is represented exactly once per round.
|
|
rng.shuffle(nodes)
|
|
qualified: list[Operator] = []
|
|
failures: list[Operator] = []
|
|
tickets_by_owner = {op.id: 0 for op in living}
|
|
for node_owner in nodes:
|
|
if rng.random() < probabilities[0]:
|
|
qualified.append(node_owner)
|
|
tickets_by_owner[node_owner.id] += 1
|
|
else:
|
|
failures.append(node_owner)
|
|
for stage in (2,3):
|
|
if not failures:
|
|
break
|
|
rng.shuffle(failures)
|
|
again = []
|
|
# Current rival outcomes remain unknown, only previous-round forecast used.
|
|
for op in failures:
|
|
cost = window_fee(stage, op.recent, window_model)
|
|
prospective_f3 = window_fee(3, op.recent, window_model) if stage == 2 else 0.
|
|
if not should_buy_window(
|
|
policy=window_policy, op_balance=op.balance, charge=cost,
|
|
stage=stage, own_tickets=tickets_by_owner[op.id],
|
|
rival_estimate=last_rival_tickets[op.id], reward=reward,
|
|
probabilities=probabilities, future_charge=prospective_f3):
|
|
continue
|
|
op.balance -= cost
|
|
op.window_paid += cost
|
|
fund += cost
|
|
fund_in += cost
|
|
if stage == 2:
|
|
op.paid_f2 += 1
|
|
else:
|
|
op.paid_f3 += 1
|
|
if rng.random() < probabilities[stage-1]:
|
|
qualified.append(op)
|
|
tickets_by_owner[op.id] += 1
|
|
elif stage == 2:
|
|
again.append(op)
|
|
failures = again
|
|
for op in qualified:
|
|
op.tickets += 1
|
|
total_tickets += len(qualified)
|
|
if qualified:
|
|
winner = rng.choice(qualified)
|
|
winner.reward(day, reward)
|
|
minted += reward
|
|
day_wins += 1
|
|
else:
|
|
no_ticket_rounds += 1
|
|
# Update forecasts solely from number of *actual tickets in past round*.
|
|
total = len(qualified)
|
|
for op in living:
|
|
rivals_now = total - tickets_by_owner[op.id]
|
|
last_rival_tickets[op.id] = .95*last_rival_tickets[op.id] + .05*rivals_now
|
|
payout_today = 0.
|
|
if fund_mode == "redistribute" and (day+1) % payout_interval == 0:
|
|
payout_today = fund * payout_share
|
|
payout_per_operator = payout_today / len(living)
|
|
for op in living:
|
|
op.balance += payout_per_operator
|
|
op.rebated += payout_per_operator
|
|
fund -= payout_today
|
|
fund_out += payout_today
|
|
balances = [op.balance for op in living]
|
|
sum_wallets = sum(op.balance for op in persons)
|
|
expected_supply = initial_balance*len(persons) + minted
|
|
if not math.isclose(sum_wallets + fund, expected_supply, rel_tol=1e-9, abs_tol=1e-5):
|
|
raise AssertionError("SUPPLY CONSERVATION FAILED")
|
|
if any(op.balance < -EPS for op in persons) or fund < -EPS:
|
|
raise AssertionError("negative wallet or fund")
|
|
daily.append({
|
|
"day": day+1, "active_operators": len(living),
|
|
"minted": round(minted, 6), "fund": round(fund, 6),
|
|
"fund_in": round(fund_in, 6), "fund_out": round(fund_out, 6),
|
|
"fund_payout_today": round(payout_today, 6),
|
|
"wallet_total": round(sum_wallets, 6),
|
|
"fund_percent_of_supply": round(100*fund/expected_supply, 5) if expected_supply else 0.,
|
|
"gini_active_wallets": round(gini(balances), 6),
|
|
"active_workers_per_round": round(day_activations / rounds_per_day, 4),
|
|
"wins_today": day_wins,
|
|
})
|
|
nrounds = days*rounds_per_day
|
|
fund_paid_worker = sum(op.worker_paid for op in persons)
|
|
fund_paid_window = sum(op.window_paid for op in persons)
|
|
assert sum(op.wins for op in persons) + no_ticket_rounds == nrounds
|
|
assert math.isclose(fund_paid_worker+fund_paid_window, fund_in, rel_tol=1e-8, abs_tol=1e-5)
|
|
assert math.isclose(fund_in - fund_out, fund, rel_tol=1e-8, abs_tol=1e-5)
|
|
assert math.isclose(sum(op.rebated for op in persons), fund_out, rel_tol=1e-8, abs_tol=1e-5)
|
|
assert math.isclose(sum(op.earned for op in persons), minted, rel_tol=1e-8, abs_tol=1e-5)
|
|
supply = initial_balance*len(persons) + minted
|
|
rows = []
|
|
for op in persons:
|
|
active_rounds = max(0, days-op.join_day) * rounds_per_day
|
|
rows.append({
|
|
"id": op.id, "capacity": op.capacity, "join_day": op.join_day,
|
|
"active_rounds": active_rounds,
|
|
"avg_workers_active": round(op.activation_total/active_rounds, 5) if active_rounds else 0.,
|
|
"wins": op.wins, "tickets": op.tickets,
|
|
"earned": round(op.earned, 5),
|
|
"paid_worker": round(op.worker_paid, 5),
|
|
"paid_window": round(op.window_paid, 5),
|
|
"fund_received": round(op.rebated, 5),
|
|
"mining_net": round(op.earned-op.fees, 5),
|
|
"wallet": round(op.balance, 5),
|
|
"paid_f2": op.paid_f2, "paid_f3": op.paid_f3,
|
|
"budget_restrictions": op.refused_budget,
|
|
"recent90": round(op.recent, 5)
|
|
})
|
|
return {
|
|
"params": {"operators": operators, "newcomers": newcomers, "join_day": join_day,
|
|
"capacities": list(capacities), "days": days, "rounds_per_day": rounds_per_day,
|
|
"seed": seed, "worker_mode": worker_mode, "worker_scale": worker_scale,
|
|
"fund_mode": fund_mode, "payout_share": payout_share,
|
|
"payout_interval": payout_interval,
|
|
"window_policy": window_policy, "window_model": window_model,
|
|
"profile": profile, "probabilities": list(probabilities),
|
|
"reward": reward, "initial_balance": initial_balance},
|
|
"summary": {
|
|
"rounds": nrounds, "minted": round(minted, 5),
|
|
"genesis_supply": initial_balance*len(persons),
|
|
"total_supply": round(supply, 5), "burned": 0.,
|
|
"fund_balance": round(fund, 5),
|
|
"fund_received_fees": round(fund_in, 5),
|
|
"fund_distributed": round(fund_out, 5),
|
|
"fund_paid_worker": round(fund_paid_worker, 5),
|
|
"fund_paid_window": round(fund_paid_window, 5),
|
|
"fund_pct_supply": round(100*fund/supply, 6) if supply else 0.,
|
|
"total_wallets": round(sum(op.balance for op in persons), 5),
|
|
"gini_operator_wallets": round(gini([op.balance for op in persons]), 6),
|
|
"no_ticket_rounds": no_ticket_rounds,
|
|
"ticket_count": total_tickets,
|
|
"total_avg_workers": round(sum(op.activation_total for op in persons)/nrounds, 4),
|
|
"total_wins_newcomers": sum(op.wins for op in persons if op.join_day > 0),
|
|
"total_fund_to_newcomers": round(sum(op.rebated for op in persons if op.join_day > 0), 5),
|
|
"total_fund_to_old": round(sum(op.rebated for op in persons if op.join_day == 0), 5),
|
|
}, "operators": rows, "daily": daily,
|
|
"limitations": [
|
|
"RESEARCH ONLY: no PoW, signatures, block times, network outages, adversarial miners, exchange prices or actual hardware.",
|
|
"One operator per registered identity is assumed; no Sybil resistance or proof of physical hardware.",
|
|
"All operators have one free Hub; capacities are the number of additional paid Workers, not extra free Hubs.",
|
|
"Dynamic workers use an imperfect myopic F1-only expected-share estimate and a past-round EMA of rival tickets.",
|
|
"Window strategy is an approximation, not an optimal game-theoretic solution.",
|
|
"Redistribute allocates every recipient an EQUAL share, including the miner, and is NOT proposed governance or treasury policy.",
|
|
"Future joiners receive their configurable genesis balance at genesis; only begin competing and receiving payouts upon joining.",
|
|
"Fund is an internal ledger with conservation checks; no real coins or wallet keys, and no consensus code.",
|
|
"72 or 720 example mining rounds/day and 1 PTM per winning round are not decided protocol parameters.",
|
|
"Float amounts are valid for research only, never consensus.",
|
|
]}
|
|
|
|
|
|
def aggregate(results: list[dict]) -> list[dict]:
|
|
grouped = {}
|
|
for r in results:
|
|
p = r["params"]
|
|
key = (p["worker_scale"], p["worker_mode"], p["fund_mode"])
|
|
grouped.setdefault(key, []).append(r)
|
|
table = []
|
|
for (scale, worker_mode, fund_mode), runs in sorted(grouped.items()):
|
|
def mean(key: str) -> float:
|
|
return statistics.mean(r["summary"][key] for r in runs)
|
|
rows = [op for r in runs for op in r["operators"]]
|
|
cap_stats = {}
|
|
for cap in sorted({op["capacity"] for op in rows}):
|
|
records = [op for op in rows if op["capacity"] == cap and op["join_day"] == 0]
|
|
if records:
|
|
cap_stats[f"capacity_{cap}_net"] = round(statistics.mean(op["mining_net"] for op in records),4)
|
|
cap_stats[f"capacity_{cap}_wallet"] = round(statistics.mean(op["wallet"] for op in records),4)
|
|
cap_stats[f"capacity_{cap}_gross"] = round(statistics.mean(op["earned"] for op in records),4)
|
|
cap_stats[f"capacity_{cap}_rebate"] = round(statistics.mean(op["fund_received"] for op in records),4)
|
|
cap_stats[f"capacity_{cap}_activated"] = round(statistics.mean(op["avg_workers_active"] for op in records),4)
|
|
newcomer_rows = [op for op in rows if op["join_day"] > 0]
|
|
incumbent_rows = [op for op in rows if op["join_day"] == 0]
|
|
def net_per_active_day(records: list[dict]) -> float:
|
|
return statistics.mean(op["mining_net"] / (op["active_rounds"] / runs[0]["params"]["rounds_per_day"])
|
|
for op in records if op["active_rounds"] > 0) if records else 0.
|
|
summary = {"worker_scale": scale, "worker_mode": worker_mode,
|
|
"fund_mode": fund_mode, "seeds": len(runs),
|
|
"fund_balance": round(mean("fund_balance"), 4),
|
|
"fund_pct_supply": round(mean("fund_pct_supply"), 4),
|
|
"fund_distributed": round(mean("fund_distributed"), 4),
|
|
"fund_worker_in": round(mean("fund_paid_worker"), 4),
|
|
"fund_window_in": round(mean("fund_paid_window"), 4),
|
|
"minted": round(mean("minted"), 4),
|
|
"gini_wallets": round(mean("gini_operator_wallets"), 5),
|
|
"avg_workers_total": round(mean("total_avg_workers"), 4),
|
|
"newcomer_wins": round(mean("total_wins_newcomers"), 3),
|
|
"newcomer_net_daily": round(net_per_active_day(newcomer_rows), 4),
|
|
"incumbent_net_daily": round(net_per_active_day(incumbent_rows), 4)}
|
|
summary.update(cap_stats)
|
|
table.append(summary)
|
|
return table
|
|
|
|
|
|
def write_output(folder: Path, results: list[dict], table: list[dict], name: str) -> list[Path]:
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
files = [folder / (name + x) for x in ("_summary.txt", "_comparison.csv", "_operators.csv", "_daily.csv", "_results.json")]
|
|
txt, comp, ops, days, js = files
|
|
comp_cols = list(dict.fromkeys(key for r in table for key in r))
|
|
with comp.open("w", newline="", encoding="utf-8") as fh:
|
|
wr = csv.DictWriter(fh, fieldnames=comp_cols)
|
|
wr.writeheader()
|
|
wr.writerows(table)
|
|
with ops.open("w", newline="", encoding="utf-8") as fh:
|
|
cols = ["worker_scale", "worker_mode", "fund_mode", "seed"] + list(results[0]["operators"][0])
|
|
wr = csv.DictWriter(fh, fieldnames=cols)
|
|
wr.writeheader()
|
|
for r in results:
|
|
for op in r["operators"]:
|
|
wr.writerow({"worker_scale": r["params"]["worker_scale"],
|
|
"worker_mode": r["params"]["worker_mode"],
|
|
"fund_mode": r["params"]["fund_mode"],
|
|
"seed": r["params"]["seed"], **op})
|
|
with days.open("w", newline="", encoding="utf-8") as fh:
|
|
cols = ["worker_scale", "worker_mode", "fund_mode", "seed"] + list(results[0]["daily"][0])
|
|
wr = csv.DictWriter(fh, fieldnames=cols)
|
|
wr.writeheader()
|
|
for r in results:
|
|
for d in r["daily"]:
|
|
wr.writerow({"worker_scale": r["params"]["worker_scale"],
|
|
"worker_mode": r["params"]["worker_mode"],
|
|
"fund_mode": r["params"]["fund_mode"],
|
|
"seed": r["params"]["seed"], **d})
|
|
js.write_text(json.dumps({"scenario_count": len(results), "comparisons": table, "runs": results},
|
|
ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
header = ["POTATIUM | Dynamische Worker / Gebühren / Fonds | FORSCHUNG, KEINE PROTOKOLLREGEL",
|
|
f"Simulationen: {len(results)} | Seeds pro Konfiguration: {table[0]['seeds']}",
|
|
"Worker-Fee-Skala Modus Fonds Ø Fonds-PTM Ø Fonds-% Ø Ausgaben Gini Ø aktive Worker"]
|
|
for r in table:
|
|
header.append(f"{r['worker_scale']:>6}x {r['worker_mode']:<8} {r['fund_mode']:<12} "
|
|
f"{r['fund_balance']:>10.2f} {r['fund_pct_supply']:>7.2f}% "
|
|
f"{r['fund_distributed']:>11.2f} {r['gini_wallets']:>5.3f} "
|
|
f"{r['avg_workers_total']:>10.2f}")
|
|
header += ["", "WARNUNGEN:"] + ["- " + s for s in results[0]["limitations"]]
|
|
txt.write_text("\n".join(header)+"\n", encoding="utf-8")
|
|
print("\n".join(header))
|
|
return files
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
|
ap.add_argument("--operators", type=int, default=20)
|
|
ap.add_argument("--newcomers", type=int, default=5)
|
|
ap.add_argument("--join-day", type=int, default=90)
|
|
ap.add_argument("--capacities", nargs="+", type=int, default=[0,1,3,5,8,10], help="repeating operator worker-capacity distribution")
|
|
ap.add_argument("--days", type=int, default=180)
|
|
ap.add_argument("--rounds-per-day", type=int, default=72, help="720 for full earlier research cadence; 72 for quick study")
|
|
ap.add_argument("--seed", type=int, default=20261025)
|
|
ap.add_argument("--replicates", type=int, default=3)
|
|
ap.add_argument("--scales", nargs="+", type=float, default=[1.,3.,5.])
|
|
ap.add_argument("--worker-modes", nargs="+", choices=["fixed", "dynamic"], default=["fixed", "dynamic"])
|
|
ap.add_argument("--fund-modes", nargs="+", choices=["hold", "redistribute"], default=["hold", "redistribute"])
|
|
ap.add_argument("--payout-share", type=float, default=.25)
|
|
ap.add_argument("--payout-interval", type=int, default=30)
|
|
ap.add_argument("--window-policy", choices=["free", "rational", "always"], default="rational")
|
|
ap.add_argument("--window-fee-model", choices=WINDOW_SCALES, default="low")
|
|
ap.add_argument("--profile", choices=F1_F2_F3, default="balanced")
|
|
ap.add_argument("--reward", type=float, default=1.)
|
|
ap.add_argument("--initial-balance", type=float, default=0.)
|
|
ap.add_argument("--out", type=Path, default=Path.home()/"potatium-dynamic-simulation")
|
|
args = ap.parse_args()
|
|
if not (1 <= args.replicates <= 30 and args.scales and len(set(args.scales)) == len(args.scales)
|
|
and 1 <= len(args.scales) <= 10 and len(args.worker_modes) == len(set(args.worker_modes))
|
|
and len(args.fund_modes) == len(set(args.fund_modes))):
|
|
ap.error("replicates 1..30; unique nonempty scales/modes")
|
|
if any(not math.isfinite(s) or s < 0 for s in args.scales):
|
|
ap.error("scales must be finite and nonnegative")
|
|
if args.newcomers and args.join_day >= args.days:
|
|
ap.error("newcomers must arrive within simulation days")
|
|
if not 0 <= args.payout_share <= 1 or args.payout_interval <= 0:
|
|
ap.error("invalid payout configuration")
|
|
if any(c < 0 or c > MAX_WORKERS for c in args.capacities):
|
|
ap.error("capacities outside 0..10")
|
|
results = []
|
|
configs = [(scale, mode, fund, args.seed+i) for scale in args.scales
|
|
for mode in args.worker_modes for fund in args.fund_modes
|
|
for i in range(args.replicates)]
|
|
for i, (scale, mode, fund, seed) in enumerate(configs, 1):
|
|
print(f"[{i}/{len(configs)}] workers={mode}, fee={scale}x, fund={fund}, seed={seed}", flush=True)
|
|
results.append(simulate(
|
|
operators=args.operators, newcomers=args.newcomers, join_day=args.join_day,
|
|
capacities=tuple(args.capacities), days=args.days, rounds_per_day=args.rounds_per_day,
|
|
seed=seed, worker_mode=mode, worker_scale=scale, fund_mode=fund,
|
|
payout_share=args.payout_share, payout_interval=args.payout_interval,
|
|
window_policy=args.window_policy, window_model=args.window_fee_model,
|
|
profile=args.profile, reward=args.reward, initial_balance=args.initial_balance))
|
|
comparisons = aggregate(results)
|
|
tag_dict = {"operators": args.operators,"newcomers":args.newcomers,"join_day":args.join_day,
|
|
"capacities":args.capacities,"days":args.days,"rounds":args.rounds_per_day,
|
|
"seed":args.seed,"replicates":args.replicates,"scales":args.scales,
|
|
"worker_modes":args.worker_modes,"fund_modes":args.fund_modes,
|
|
"payout_share":args.payout_share,"payout_interval":args.payout_interval,
|
|
"policy":args.window_policy,"window_fee":args.window_fee_model,
|
|
"profile":args.profile,"initial_balance":args.initial_balance,"reward":args.reward}
|
|
tag = hashlib.sha256(json.dumps(tag_dict,sort_keys=True).encode()).hexdigest()[:10]
|
|
stem = f"potatium_dynamic_{args.operators}o_{args.days}d_{args.rounds_per_day}r_{tag}"
|
|
produced = write_output(args.out, results, comparisons, stem)
|
|
archive = args.out / (stem + "_ERGEBNISSE.zip")
|
|
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED, compresslevel=7) as z:
|
|
for path in produced:
|
|
z.write(path, arcname=path.name)
|
|
with zipfile.ZipFile(archive) as z:
|
|
if z.testzip() is not None:
|
|
raise RuntimeError("Ergebnisarchiv ist beschaedigt")
|
|
print("\nDateien:\n" + "\n".join(str(x) for x in produced))
|
|
print("\nZIP fuer Auswertung: " + str(archive))
|
|
|
|
if __name__ == "__main__":
|
|
main()
|