docs: update Potatium v0.6 architecture and PoP research

This commit is contained in:
Michael Böhmländer committed 2026-10-25 11:45:32 +00:00
1 parent 75df8a5c0d
commit 862ccf1d03
34 files changed
+3257 -18

No files matched your search

+301
View File
@@ -0,0 +1,301 @@
#!/usr/bin/env python3
"""Potatium: research-only fee allocation from an existing cluster simulation.
No simulation state is modified. This is an ACCOUNTING COUNTERFACTUAL, not
an incentive-aware rerun: miners still make the same decisions because fees
are paid to recipient accounts outside the participating miner population.
Input: existing pop_cluster_*.json produced by simulate_pop_cluster.py,
either in a directory or inside a ZIP (including the user's archive).
Output: TXT, CSV, JSON and one ZIP for subsequent evaluation.
All float calculations are OFF CHAIN and never consensus implementation.
"""
from __future__ import annotations
import argparse
import csv
import json
import math
import statistics
import zipfile
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
MAX_FILE_SIZE = 30 * 1024 * 1024
MAX_INPUT_FILES = 100
TOLERANCE = 0.002 # old reports round wallet balances to six decimals
@dataclass(frozen=True)
class Split:
"""Fractions for (burn, validator, treasury). Must sum to exactly 1."""
burn: float
validators: float
treasury: float
def __post_init__(self):
if any(not math.isfinite(x) or x < 0 or x > 1 for x in (self.burn, self.validators, self.treasury)):
raise ValueError('Split fractions must be finite in [0,1]')
if not math.isclose(sum((self.burn, self.validators, self.treasury)), 1.0, abs_tol=1e-9):
raise ValueError('Split fractions must sum to 1')
@dataclass(frozen=True)
class Policy:
name: str
worker: Split
window: Split
def presets() -> list[Policy]:
burn = Split(1, 0, 0)
validator = Split(0, 1, 0)
treasury = Split(0, 0, 1)
return [
Policy('burn_100', burn, burn),
Policy('validators_100', validator, validator),
Policy('treasury_100', treasury, treasury),
Policy('validators_80_treasury_20', Split(0, .8, .2), Split(0, .8, .2)),
Policy('validators_70_treasury_25_burn_5', Split(.05, .7, .25), Split(.05, .7, .25)),
Policy('worker_treasury_window_validators', treasury, validator),
]
def parse_split(value: str) -> Split:
try:
parts = [float(x.strip()) for x in value.split(',')]
except ValueError as ex:
raise ValueError('Expected three comma-separated percentages (burn,validators,treasury)') from ex
if len(parts) != 3:
raise ValueError('Expected exactly three percentages (burn,validators,treasury)')
if not math.isclose(sum(parts), 100, rel_tol=0, abs_tol=1e-7):
raise ValueError('Percentages must sum to 100')
return Split(*(p / 100 for p in parts))
def _read_json(data: bytes, name: str) -> list[dict]:
try:
doc = json.loads(data)
except (ValueError, UnicodeError) as ex:
raise ValueError(f'{name}: invalid JSON: {ex}') from ex
if not isinstance(doc, dict) or not isinstance(doc.get('scenarios'), list) or not doc['scenarios']:
raise ValueError(f'{name}: expected JSON containing nonempty scenarios[]')
return doc['scenarios']
def load_sources(path: Path) -> tuple[list[dict], list[str]]:
"""Never extract ZIP content; parse only recognized JSON entries by name."""
found = []
if path.is_dir():
for f in path.rglob('pop_cluster_*.json'):
if not f.is_file() or f.stat().st_size > MAX_FILE_SIZE:
raise ValueError(f'oversized/inaccessible input file {f}')
found.append((str(f), f.read_bytes()))
elif path.is_file() and path.suffix.lower() == '.zip':
with zipfile.ZipFile(path) as archive:
for item in archive.infolist():
if item.is_dir() or not Path(item.filename).name.startswith('pop_cluster_') or not item.filename.endswith('.json'):
continue
if item.file_size > MAX_FILE_SIZE:
raise ValueError(f'ZIP member exceeds size limit: {item.filename}')
found.append((item.filename, archive.read(item)))
elif path.is_file() and path.name.startswith('pop_cluster_') and path.suffix.lower() == '.json':
if path.stat().st_size > MAX_FILE_SIZE:
raise ValueError('JSON file exceeds size limit')
found.append((str(path), path.read_bytes()))
else:
raise ValueError('Provide ZIP, directory of pop_cluster_*.json, or a pop_cluster_*.json file')
if not found or len(found) > MAX_INPUT_FILES:
raise ValueError(f'Expected 1..{MAX_INPUT_FILES} pop_cluster_*.json files, got {len(found)}')
scenarios = []
for name, blob in sorted(found):
for source in _read_json(blob, name):
scenarios.append(source)
return scenarios, [name for name, _ in sorted(found)]
def _number(v: object, desc: str) -> float:
if type(v) not in (int, float) or not math.isfinite(v):
raise ValueError(f'missing/invalid {desc}')
return float(v)
def key_for(parameters: dict) -> str:
"""Research group with target worker count and seed removed."""
stable = {k: v for k, v in parameters.items() if k not in ('target_workers', 'seed')}
return json.dumps(stable, sort_keys=True, separators=(',', ':'))
def project(record: dict, policy: Policy, validators: int) -> dict:
if not (isinstance(validators, int) and validators > 0):
raise ValueError('validators must be positive')
if not isinstance(record, dict):
raise ValueError('invalid scenario')
p, s, ops = (record.get('parameters'), record.get('summary'), record.get('operators'))
if not isinstance(p, dict) or not isinstance(s, dict) or not isinstance(ops, list) or not ops:
raise ValueError('scenario lacks parameters, summary or operators')
worker = _number(s.get('burned_workers'), 'burned_workers')
window = _number(s.get('burned_windows'), 'burned_windows')
old_burn = _number(s.get('burned_total'), 'burned_total')
minted = _number(s.get('minted'), 'minted')
origin = _number(p.get('initial_balance'), 'initial_balance') * len(ops)
miner_wallets = sum(_number(op.get('wallet'), 'operator.wallet') for op in ops)
if min(worker, window, minted, origin, miner_wallets) < -1e-8:
raise ValueError('negative source accounting amount')
if abs(old_burn - worker - window) > TOLERANCE:
raise ValueError('source fee components do not match')
if abs((minted + origin - old_burn) - miner_wallets) > TOLERANCE:
raise ValueError('source accounting not conserved (operator wallets vs mint minus fees)')
burn = worker * policy.worker.burn + window * policy.window.burn
paid_validators = worker * policy.worker.validators + window * policy.window.validators
paid_treasury = worker * policy.worker.treasury + window * policy.window.treasury
total_fees = worker + window
if not math.isclose(total_fees, burn + paid_validators + paid_treasury, rel_tol=1e-10, abs_tol=1e-6):
raise AssertionError('fee destination split violated conservation')
outstanding = minted + origin - burn
cash_with_users_and_validators = miner_wallets + paid_validators
if not math.isclose(outstanding, cash_with_users_and_validators + paid_treasury, rel_tol=1e-10, abs_tol=TOLERANCE):
raise AssertionError('post-fee balances violate conservation')
# A fee diverted to an external account remains spent by its originating miner.
return {
'policy': policy.name,
'group': key_for(p),
'target_workers': p.get('target_workers'),
'seed': p.get('seed'),
'minted': minted,
'genesis': origin,
'gross_supply': minted + origin,
'fees_worker': worker,
'fees_window': window,
'fees_total': total_fees,
'burned': burn,
'burn_pct_minted': 100 * burn / minted if minted else 0.0,
'paid_validators': paid_validators,
'validator_each': paid_validators / validators,
'paid_treasury': paid_treasury,
'outstanding_supply': outstanding,
'miner_wallets': miner_wallets,
'non_treasury_balances': cash_with_users_and_validators,
'treasury_share_outstanding_pct': (100 * paid_treasury / outstanding if outstanding else 0.0),
'target_miner_net': _number(s.get('target_net'), 'target_net'),
'target_win_pct': _number(s.get('target_win_percent'), 'target_win_percent'),
'gini_miners_only': _number(s.get('gini_operator_wallets'), 'gini_operator_wallets'),
'validators': validators,
'operators': len(ops),
'other_workers': p.get('others_workers'),
'worker_fee_scale': p.get('worker_fee_scale'),
'newcomers': p.get('newcomers'),
'days': p.get('days'),
}
def summarize(records: list[dict]) -> list[dict]:
by = defaultdict(list)
for r in records:
by[(r['group'], r['target_workers'], r['policy'])].append(r)
metrics = ('minted', 'fees_worker', 'fees_window', 'fees_total', 'burned',
'burn_pct_minted', 'paid_validators', 'validator_each', 'paid_treasury',
'outstanding_supply', 'miner_wallets', 'non_treasury_balances',
'treasury_share_outstanding_pct', 'target_miner_net', 'target_win_pct',
'gini_miners_only')
rows = []
for (_, _, _), v in sorted(by.items(), key=lambda kv: (kv[1][0]['other_workers'], kv[1][0]['worker_fee_scale'], kv[0][1], kv[0][2])):
r = v[0]
rows.append({k: r[k] for k in ('group', 'target_workers', 'policy', 'other_workers',
'worker_fee_scale', 'newcomers', 'days', 'validators', 'operators')}
| {'seeds': len(v), 'seed_list': ','.join(str(x['seed']) for x in v)}
| {k: statistics.mean(x[k] for x in v) for k in metrics})
return rows
def _format_line(r: dict) -> str:
return (f"{r['target_workers']:>2} {r['policy']:<38} "
f"{r['fees_total']:>11,.1f} {r['burned']:>11,.1f} "
f"{r['paid_validators']:>11,.1f} {r['paid_treasury']:>11,.1f} "
f"{r['outstanding_supply']:>13,.1f} {r['target_miner_net']:>12,.1f}")
def output(out: Path, rows: list[dict], runs: list[dict], inputs: list[str]) -> list[Path]:
out.mkdir(parents=True, exist_ok=True)
t, c, d, j, z = [out / f for f in ('fee_distribution_summary.txt',
'fee_distribution_comparison.csv',
'fee_distribution_per_run.csv',
'fee_distribution.json',
'potatium-fee-analysis.zip')]
for dest, content in ((c, rows), (d, runs)):
with dest.open('w', newline='', encoding='utf-8') as f:
writer = csv.DictWriter(f, fieldnames=list(content[0]))
writer.writeheader()
writer.writerows(content)
j.write_text(json.dumps({'inputs': inputs, 'aggregates': rows, 'runs': runs},
indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
lines = ['POTATIUM | Gebuehrenverteilung | FORSCHUNG, KEINE PROTOKOLLREGEL',
'Gleiche Mining-Ergebnisse je Szenario; nur das Ziel der Gebuehren wird umgebucht.',
'Validatoren sind hier EXTERNE Empfaenger und nehmen selbst NICHT am Mining teil.',
'Fonds-Guthaben ist nicht verbrannt, aber im Modell vorerst nicht ausgabefreigegeben.',
'Guthaben der Miner / Mining-Strategien bleiben daher bei allen Policies identisch.',
'Alle Betragswerte in PTM. Float nur fuer Forschungsrechnung.', '']
groups = defaultdict(list)
for r in rows:
groups[r['group']].append(r)
for ix, (_, group) in enumerate(groups.items(), 1):
first = group[0]
lines.extend([f'SZENARIO {ix}: {first["operators"]} Betreiber (inkl. Neuer), '
f'andere Worker={first["other_workers"]}, '
f'Tariffaktor={first["worker_fee_scale"]}, Neue={first["newcomers"]}, '
f'Tage={first["days"]}, je W {first["seeds"]} Seeds',
'W Gebuehrenziel Gebuehren Burn Validatoren Fonds Supply gesamt Netto Miner'])
# Only representative worker counts to keep TXT manageable.
workers = sorted({r['target_workers'] for r in group})
selected = {x for x in (0, 3, 7, 8, 10) if x in workers}
if not selected: selected = set(workers)
for r in group:
if r['target_workers'] in selected:
lines.append(_format_line(r))
lines.append('')
lines += ['GRENZEN / KEIN BEWEIS FUER VERTEILUNGSGERECHTIGKEIT:',
'* Alle Gebuehren sind bereits in den Simulator-Ergebnissen bezahlt.',
'* Validator-/Fonds-Zufluesse werden nicht als Miningbudget wieder ausgegeben.',
'* Validatoren koennen real auch Miner sein; Rueckfluesse muessen separat simuliert werden.',
'* Administrations-/Treasury-Governance, Transfers, Sybil und reale CPU fehlen.',
'* Der Fonds kann einen grossen Teil der PTM blockieren, obwohl nichts verbrannt wird.',
'* Bei anderen Verwendungszwecken und geaenderten Anreizen muss PoP neu simuliert werden.',
'* Keine verbindliche Coin-/Gebuehrenregel, kein On-chain-Float.']
t.write_text('\n'.join(lines) + '\n', encoding='utf-8')
with zipfile.ZipFile(z, 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
for f in (t, c, d, j):
archive.write(f, arcname=f.name)
return [t, c, d, j, z]
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('--input', type=Path, required=True, help='prior ZIP, JSON file, or directory with pop_cluster_*.json')
parser.add_argument('--out', type=Path, default=Path.home() / 'potatium-fee-analysis')
parser.add_argument('--validators', type=int, default=4, help='separate validator recipients in this accounting test')
parser.add_argument('--custom-worker', type=str, help='percentage triplet burn,validator,treasury, e.g. 0,50,50')
parser.add_argument('--custom-window', type=str, help='percentage triplet; if omitted same as --custom-worker')
args = parser.parse_args()
try:
if args.validators < 1 or args.validators > 100:
raise ValueError('validators must be between 1 and 100')
policies = presets()
if args.custom_worker or args.custom_window:
w = parse_split(args.custom_worker or args.custom_window)
f = parse_split(args.custom_window or args.custom_worker)
policies.append(Policy('custom', w, f))
sources, files = load_sources(args.input)
runs = [project(source, policy, args.validators) for source in sources for policy in policies]
rows = summarize(runs)
paths = output(args.out, rows, runs, files)
except (ValueError, OSError, zipfile.BadZipFile, KeyError) as ex:
parser.error(str(ex))
print(f'Gelesen: {len(files)} JSON-Datei(en), {len(sources)} simulierte Szenarien, {len(policies)} Gebührenmodelle.')
for f in paths:
print(f' {f}')
print('Sende mir vorzugsweise potatium-fee-analysis.zip zur Auswertung.')
if __name__ == '__main__':
main()