|
|
|
|
@@ -19,6 +19,7 @@ import hashlib
|
|
|
|
|
import http.server
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import pathlib
|
|
|
|
|
import logging
|
|
|
|
|
import socketserver
|
|
|
|
|
import threading
|
|
|
|
|
@@ -372,6 +373,8 @@ ATTRIBUTION_WINDOW = 20
|
|
|
|
|
# Startup backfill of the rolling window from chain history is capped by
|
|
|
|
|
# wall clock so a slow node cannot hold the exporter's first poll forever.
|
|
|
|
|
BACKFILL_BUDGET_S = 180.0
|
|
|
|
|
# Attribution state is written at most this often (and on shutdown).
|
|
|
|
|
STATE_SAVE_INTERVAL_S = 10.0
|
|
|
|
|
|
|
|
|
|
GAP_BUCKETS = [0.5, 1, 2, 3, 5, 8, 12, 16, 24, 32, 48, 64, 128]
|
|
|
|
|
# Block timestamp minus the moment this exporter first saw the block, seconds.
|
|
|
|
|
@@ -479,7 +482,7 @@ def u512_le(hex_str: str) -> int:
|
|
|
|
|
|
|
|
|
|
class Arena:
|
|
|
|
|
def __init__(self, rpc, self_preimage, window_blocks, target_block_time, top_n,
|
|
|
|
|
reward_address="", telemetry=None):
|
|
|
|
|
reward_address="", telemetry=None, state_file=""):
|
|
|
|
|
self.rpc = rpc
|
|
|
|
|
self.telemetry = telemetry
|
|
|
|
|
self.reward_address = reward_address or None
|
|
|
|
|
@@ -516,6 +519,10 @@ class Arena:
|
|
|
|
|
# reporter did not lead by ATTRIBUTION_LEAD_MS count as attempts only.
|
|
|
|
|
self.attr = {}
|
|
|
|
|
self.pending_attr = deque() # (block hash, preimage, seen at)
|
|
|
|
|
self.state_file = state_file
|
|
|
|
|
self._state_dirty = False
|
|
|
|
|
self._state_saved_at = 0.0
|
|
|
|
|
self.load_state()
|
|
|
|
|
self.drift_hist = {} # preimage -> Hist(DRIFT_BUCKETS)
|
|
|
|
|
self.consecutive = {} # preimage -> blocks authored directly after its own
|
|
|
|
|
# Tip observations only — used for the block interval, which is real
|
|
|
|
|
@@ -606,6 +613,55 @@ class Arena:
|
|
|
|
|
self.drift_hist.setdefault(pre, Hist(DRIFT_BUCKETS)).observe(ts / 1000.0 - seen)
|
|
|
|
|
self.last_tip = (height, ts, pre, seen)
|
|
|
|
|
|
|
|
|
|
def load_state(self):
|
|
|
|
|
"""Restore attribution votes, held names and the peer-id -> name map
|
|
|
|
|
from the state file, so a restart does not strip every author of its
|
|
|
|
|
name for a few blocks (that churn split the per-author series)."""
|
|
|
|
|
if not self.state_file:
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
st = json.loads(pathlib.Path(self.state_file).read_text())
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
return
|
|
|
|
|
except Exception as e: # noqa: BLE001 - a bad file is ignored, not fatal
|
|
|
|
|
LOG.warning("attribution state %s unreadable, starting fresh: %s", self.state_file, e)
|
|
|
|
|
return
|
|
|
|
|
key = lambda k: tuple(k) if k else None
|
|
|
|
|
with self.lock:
|
|
|
|
|
for pre, a in st.get("attr", {}).items():
|
|
|
|
|
self.attr[pre] = {"attempts": int(a.get("attempts", 0)), "attributed": int(a.get("attributed", 0)),
|
|
|
|
|
"votes": deque((key(k) for k in a.get("votes", [])), maxlen=ATTRIBUTION_WINDOW),
|
|
|
|
|
"held": key(a.get("held"))}
|
|
|
|
|
if self.telemetry is not None:
|
|
|
|
|
with self.telemetry.lock:
|
|
|
|
|
for peer, name in st.get("peer_names", {}).items():
|
|
|
|
|
self.telemetry.peer_names.setdefault(peer, name)
|
|
|
|
|
LOG.info("attribution state restored: %s authors, %s of them named", len(st.get("attr", {})),
|
|
|
|
|
sum(1 for a in st.get("attr", {}).values() if a.get("held")))
|
|
|
|
|
|
|
|
|
|
def save_state(self, force=False):
|
|
|
|
|
if not self.state_file or not self._state_dirty:
|
|
|
|
|
return
|
|
|
|
|
if not force and time.time() - self._state_saved_at < STATE_SAVE_INTERVAL_S:
|
|
|
|
|
return
|
|
|
|
|
with self.lock:
|
|
|
|
|
attr = {pre: {"attempts": a["attempts"], "attributed": a["attributed"],
|
|
|
|
|
"votes": [list(k) if k else None for k in a["votes"]],
|
|
|
|
|
"held": list(a["held"]) if a.get("held") else None}
|
|
|
|
|
for pre, a in self.attr.items()}
|
|
|
|
|
peer_names = {}
|
|
|
|
|
if self.telemetry is not None:
|
|
|
|
|
with self.telemetry.lock:
|
|
|
|
|
peer_names = dict(self.telemetry.peer_names)
|
|
|
|
|
tmp = self.state_file + ".tmp"
|
|
|
|
|
try:
|
|
|
|
|
pathlib.Path(tmp).write_text(json.dumps({"attr": attr, "peer_names": peer_names}))
|
|
|
|
|
os.replace(tmp, self.state_file)
|
|
|
|
|
self._state_dirty = False
|
|
|
|
|
self._state_saved_at = time.time()
|
|
|
|
|
except Exception as e: # noqa: BLE001 - persistence is best effort
|
|
|
|
|
LOG.warning("attribution state not saved to %s: %s", self.state_file, e)
|
|
|
|
|
|
|
|
|
|
def resolve_attributions(self):
|
|
|
|
|
"""Join tip blocks with the feed's first-import reports once the
|
|
|
|
|
reports have had time to arrive."""
|
|
|
|
|
@@ -625,7 +681,7 @@ class Arena:
|
|
|
|
|
LOG.debug("attribution: %s by %s… first import %r lead %s ms", block_hash[:10], pre[:10], name, fi[1])
|
|
|
|
|
with self.lock:
|
|
|
|
|
a = self.attr.setdefault(pre, {"attempts": 0, "attributed": 0,
|
|
|
|
|
"votes": deque(maxlen=ATTRIBUTION_WINDOW)})
|
|
|
|
|
"votes": deque(maxlen=ATTRIBUTION_WINDOW), "held": None})
|
|
|
|
|
a["attempts"] += 1
|
|
|
|
|
key = None
|
|
|
|
|
if fi is not None:
|
|
|
|
|
@@ -634,6 +690,9 @@ class Arena:
|
|
|
|
|
key = self.telemetry.node_key(nid)
|
|
|
|
|
a["attributed"] += 1
|
|
|
|
|
a["votes"].append(key)
|
|
|
|
|
self._state_dirty = True
|
|
|
|
|
if due:
|
|
|
|
|
self.save_state()
|
|
|
|
|
|
|
|
|
|
def author_display(self, pre):
|
|
|
|
|
"""(display, node_name, peer_id, confidence). Node name when the
|
|
|
|
|
@@ -641,13 +700,35 @@ class Arena:
|
|
|
|
|
short = f"{pre[:8]}…{pre[-4:]}" if len(pre) > 14 else pre
|
|
|
|
|
with self.lock:
|
|
|
|
|
a = self.attr.get(pre)
|
|
|
|
|
votes = [k for k in a["votes"] if k is not None] if a else []
|
|
|
|
|
if not a or len(a["votes"]) < ATTRIBUTION_MIN_ATTEMPTS or not votes:
|
|
|
|
|
if not a:
|
|
|
|
|
return short, "", "", 0.0
|
|
|
|
|
key, n = max(((k, votes.count(k)) for k in set(votes)), key=lambda kv: kv[1])
|
|
|
|
|
conf = n / len(a["votes"])
|
|
|
|
|
name, peer = self.telemetry.key_name(key) if self.telemetry else ("", "")
|
|
|
|
|
if conf < ATTRIBUTION_MIN_CONFIDENCE or not name:
|
|
|
|
|
counts = {}
|
|
|
|
|
for k in a["votes"]:
|
|
|
|
|
if k is not None:
|
|
|
|
|
counts[k] = counts.get(k, 0) + 1
|
|
|
|
|
total = len(a["votes"])
|
|
|
|
|
held = a.get("held")
|
|
|
|
|
# Hysteresis: a name, once held, stays until its node has no vote
|
|
|
|
|
# left in the window or another node out-votes it outright. A dip
|
|
|
|
|
# in confidence alone never drops back to the preimage, which
|
|
|
|
|
# would split the author's series on every wobble.
|
|
|
|
|
if held is not None and counts.get(held, 0) == 0:
|
|
|
|
|
held = None
|
|
|
|
|
if counts:
|
|
|
|
|
best, n = max(counts.items(), key=lambda kv: kv[1])
|
|
|
|
|
if held is None:
|
|
|
|
|
if total >= ATTRIBUTION_MIN_ATTEMPTS and n / total >= ATTRIBUTION_MIN_CONFIDENCE:
|
|
|
|
|
held = best
|
|
|
|
|
elif best != held and n > counts[held]:
|
|
|
|
|
held = best
|
|
|
|
|
if held != a.get("held"):
|
|
|
|
|
a["held"] = held
|
|
|
|
|
self._state_dirty = True
|
|
|
|
|
conf = (counts[held] / total) if held else ((max(counts.values()) / total) if counts and total else 0.0)
|
|
|
|
|
if held is None:
|
|
|
|
|
return short, "", "", conf
|
|
|
|
|
name, peer = self.telemetry.key_name(held) if self.telemetry else ("", "")
|
|
|
|
|
if not name:
|
|
|
|
|
return short, "", "", conf
|
|
|
|
|
return name, name, peer, conf
|
|
|
|
|
|
|
|
|
|
@@ -805,16 +886,22 @@ class Arena:
|
|
|
|
|
# `display` is what the leaderboard shows in place of the preimage.
|
|
|
|
|
out.append("# HELP quantus_author_node_info Reward preimage to telemetry node attribution, by first block import. Value is the confidence; display is the node name when confident, else the abbreviated preimage.")
|
|
|
|
|
out.append("# TYPE quantus_author_node_info gauge")
|
|
|
|
|
names_used = {}
|
|
|
|
|
# A name used by more than one node on the feed always carries the
|
|
|
|
|
# peer id suffix, whether or not the others are attributed to anyone:
|
|
|
|
|
# deciding it from the attributed set alone re-labelled the first
|
|
|
|
|
# QuantusMinerGUI the moment a second one was named.
|
|
|
|
|
peers_by_name = {}
|
|
|
|
|
if self.telemetry is not None:
|
|
|
|
|
with self.telemetry.lock:
|
|
|
|
|
for peer, name in self.telemetry.peer_names.items():
|
|
|
|
|
peers_by_name.setdefault(name, set()).add(peer)
|
|
|
|
|
rows = []
|
|
|
|
|
for pre in keep:
|
|
|
|
|
display, name, peer, conf = self.author_display(pre)
|
|
|
|
|
rows.append([pre, display, name, peer, conf])
|
|
|
|
|
if name:
|
|
|
|
|
names_used.setdefault(name, set()).add(peer)
|
|
|
|
|
for row in rows:
|
|
|
|
|
pre, display, name, peer, conf = row
|
|
|
|
|
if name and len(names_used.get(name, ())) > 1:
|
|
|
|
|
if name and len(peers_by_name.get(name, ())) > 1:
|
|
|
|
|
display = f"{name} ({peer[-6:]})" if peer else name
|
|
|
|
|
is_self = "true" if pre == self.self_preimage else "false"
|
|
|
|
|
esc = lambda s: s.replace('\\', '\\\\').replace('"', '\\"')
|
|
|
|
|
@@ -948,6 +1035,8 @@ def main():
|
|
|
|
|
help="substrate-telemetry feed websocket; empty disables telemetry")
|
|
|
|
|
p.add_argument("--reward-address", default="",
|
|
|
|
|
help="our wormhole SS58 address, to track accrued rewards")
|
|
|
|
|
p.add_argument("--state-file", default="",
|
|
|
|
|
help="JSON file for attribution votes and held names, so restarts keep author names")
|
|
|
|
|
p.add_argument("--self-preimage", default="",
|
|
|
|
|
help="our own --rewards-inner-hash, to label and score our share")
|
|
|
|
|
args = p.parse_args()
|
|
|
|
|
@@ -963,7 +1052,8 @@ def main():
|
|
|
|
|
threading.Thread(target=telemetry.run_forever, daemon=True).start()
|
|
|
|
|
|
|
|
|
|
arena = Arena(rpc, args.self_preimage, args.window_blocks,
|
|
|
|
|
args.target_block_time, args.top_n, args.reward_address, telemetry)
|
|
|
|
|
args.target_block_time, args.top_n, args.reward_address, telemetry,
|
|
|
|
|
state_file=args.state_file)
|
|
|
|
|
Handler.arena = arena
|
|
|
|
|
|
|
|
|
|
def loop():
|
|
|
|
|
|