Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions docs/Generative-Mining.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,60 @@ gascli generator restart
gascli generator delete
```

### Checking Your Performance

```bash
gascli g perf # Image/video charts and verification stats
gascli g perf --modality image # Image only
gascli g perf --lookback-days 14 # Two weeks of chart history
gascli g perf --json # Raw API response, without terminal formatting
gascli g perf --no-chain # Skip chain RPC; keep benchmark charts
gascli g perf --netuid 379 --chain-endpoint test # Explicit testnet chain context
```

Rich panels and Plotext's high-resolution curves show **sample-weighted rolling
seven-day fool rates**, with muted threshold lines and dated axes. Each modality
needs at least 20 benchmark evaluations and a
rate strictly above its threshold: 2% for images, 1% for videos. Changing
`--lookback-days` changes the chart span (and the legacy aggregate window), not
the seven-day qualification window.

Counts are evaluations, not unique media: the same media can be evaluated in
multiple benchmark runs. Missing data is shown as a gap, not a zero. Historical
points are reconstructed from currently available benchmark results; they are
not a record of what validators knew or paid at that time.

Fool-rate eligibility is **not a guarantee of on-chain incentive**. Verified
activity, validator updates, and weight reveal/epoch timing also affect rewards.
Not participating in video does not disqualify an image miner. The CLI does not
infer participation from missing benchmark data.

The on-chain panel reads the selected hotkey's current normalized incentive
score (not a token amount), current revealed weights toward it, and the latest
submission block/time for the five largest permitted validators by stake.
Amber vertical chart markers match those submission timestamps. These are the
**latest submission per validator, not a complete event history**. On commit-reveal
subnets, `LastUpdate` records commits, not reveals; the currently revealed weight
may belong to an earlier commit. No pending-payment or reveal ETA is inferred.

Chain values are read at one metagraph block, with its timestamp shown separately
from the benchmark snapshot. Timestamp lookups older than 7,200 blocks are skipped
to avoid archive-node delays; their exact submission blocks remain visible.
Missing/failed RPC data is shown as unknown, not zero. `--chain-timeout` bounds the
lookup (default 30 seconds); partial snapshots remain useful on timeout.
`--netuid` / `--chain-endpoint` override `BT_NETUID` / `BT_CHAIN_ENDPOINT`, falling
back to subnet 34 on finney. Select the correct network when using a non-production
API. `--json` remains the raw API response and does not perform chain queries.

The API supplies the chart history. Older API deployments still work, displaying
the aggregate summary until the history addition is deployed. Existing CLI
versions continue to read the unchanged verification and aggregate fields.

Charts use the available terminal width; very narrow terminals show the numbers
without a plot. Redirected output remains free of color escape sequences, and
`NO_COLOR` disables colors. To preview the layout without a wallet or API call,
run `python -m scripts.preview_generator_performance --width 100` (demo data).

### API Endpoints

Your miner exposes these endpoints for validators:
Expand Down
32 changes: 25 additions & 7 deletions gas/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -896,10 +896,14 @@ def info():
help="Bittensor hotkey name (default: BT_WALLET_HOTKEY from .env.gen_miner, else 'default')",
)
@click.option("--modality", type=click.Choice(["image", "video", "audio"]), default=None, help="Filter fool-rate aggregate by modality")
@click.option("--lookback-days", default=7, type=int, show_default=True, help="Fool-rate parquet lookback (days)")
@click.option("--lookback-days", default=7, type=click.IntRange(1, 90), show_default=True, help="Chart/aggregate lookback; qualification always uses 7 days")
@click.option("--api-url", default=None, help="GAS API base URL (default: GAS_API_URL env or production)")
@click.option("--chain/--no-chain", default=True, help="Show current incentive and latest validator submissions (text output only)")
@click.option("--netuid", type=click.IntRange(0, 65535), default=None, help="Chain subnet (default: BT_NETUID or 34)")
@click.option("--chain-endpoint", default=None, help="Chain network/endpoint (default: BT_CHAIN_ENDPOINT or finney)")
@click.option("--chain-timeout", type=click.IntRange(1, 120), default=30, show_default=True, help="Maximum seconds for the optional chain lookup")
@click.option("--json", "as_json", is_flag=True, help="Print raw API JSON")
def gen_perf(wallet_name, wallet_hotkey, modality, lookback_days, api_url, as_json):
def gen_perf(wallet_name, wallet_hotkey, modality, lookback_days, api_url, chain, netuid, chain_endpoint, chain_timeout, as_json):
"""Query your generator verification stats + aggregated fool rate (Epistula-authenticated GAS API).

The request is signed with your hotkey (same Epistula flow as discriminator ``d perf``).
Expand All @@ -920,10 +924,6 @@ def gen_perf(wallet_name, wallet_hotkey, modality, lookback_days, api_url, as_js
sys.exit(1)

addr = wallet.hotkey.ss58_address
click.echo()
click.echo(f" ⛽ generator {addr}")
click.echo()

result = fetch_generator_performance(
wallet,
modality=modality,
Expand All @@ -940,6 +940,24 @@ def gen_perf(wallet_name, wallet_hotkey, modality, lookback_days, api_url, as_js
click.echo(json_lib.dumps(data, indent=2, default=str))
return

chain_data = {"status": "disabled"}
if chain:
from gas.protocol.generator_chain import fetch_generator_chain
selected_netuid = netuid if netuid is not None else click.IntRange(0, 65535).convert(
os.environ.get("BT_NETUID", "34"), None, click.get_current_context())
chain_data = fetch_generator_chain(
addr, network=chain_endpoint or os.environ.get("BT_CHAIN_ENDPOINT") or "finney",
netuid=selected_netuid, timeout=chain_timeout)

from gas.utils.generator_performance import render_fool_history, render_chain_context
if render_fool_history(data, modality, hotkey=addr, lookback_days=lookback_days, chain=chain_data):
return
render_chain_context(chain_data)
click.echo()
click.echo(f" ⛽ generator {addr}")
click.echo(" History unavailable from this API; showing aggregate only.")
click.echo()

ver = data.get("verification") or {}
fool = data.get("fool_aggregate") or {}

Expand Down Expand Up @@ -967,7 +985,7 @@ def gen_perf(wallet_name, wallet_hotkey, modality, lookback_days, api_url, as_js
click.echo(" (no verification rows yet)")
click.echo()

click.echo(f" Fool rate aggregate (benchmark parquets, last {lookback_days}d)")
click.echo(f" Fool rate aggregate (benchmark evaluations, last {lookback_days}d)")
ts = fool.get("total_samples", 0)
if ts:
click.echo(
Expand Down
208 changes: 208 additions & 0 deletions gas/protocol/generator_chain.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
"""Bounded, read-only chain context for generator performance.

LastUpdate tracks submissions (commits on commit-reveal subnets), not reveals.
No wallet is opened and no extrinsics are submitted. RPC work runs in a child
process so a stalled endpoint cannot hang the CLI or contaminate JSON output.
"""

import json
import subprocess
import sys

PREFIX = "GAS_CHAIN_SNAPSHOT "
MAX_VALIDATORS = 5
# Avoid archive fallbacks for stale validators. Their exact block is still shown.
MAX_TIMESTAMP_AGE_BLOCKS = 7200
REVEAL_INDEX_URL = "https://api.metagraph.sh/api/v1/chain-events"
MAX_REVEAL_PAGES = 8


def add_recent_reveals(subtensor, snapshot, publish=lambda snapshot: None, get=None):
"""Use a bounded public index as a locator; verify events/times on chain.

This is latest *observed* reveal context, not a complete history or proof
that a particular commit has revealed. No wallet/miner address is sent to
the index. Only Finney's modern timelock events are indexed here.
"""
if not snapshot.get("commit_reveal_enabled") or not snapshot.get("validators"):
return
if snapshot["network"] != "finney":
snapshot["reveal_note"] = "Recent reveal lookup is available on Finney only."
return
import requests
get = get or requests.get
snapshot["reveal_note"] = "Recent indexed reveals only (Metagraphed), verified on chain; unknown ≠ not revealed."
publish(snapshot)
rows = {row["hotkey"]: row for row in snapshot["validators"]}
active = {key for key, row in rows.items() if row.get("submitted_block") and
snapshot["block"] - row["submitted_block"] <= MAX_TIMESTAMP_AGE_BLOCKS}
if not active:
return
params = dict(pallet="SubtensorModule", method="TimelockedWeightsRevealed", limit=100)
checked = set()
cursors = set()
try:
for _ in range(MAX_REVEAL_PAGES):
response = get(REVEAL_INDEX_URL, params=dict(params), timeout=3)
response.raise_for_status()
body = response.json()
if (response.headers.get("x-metagraph-degraded") or not body.get("ok") or
body.get("meta", {}).get("source") == "data-worker-unavailable"):
raise ValueError("Reveal index unavailable")
data = body["data"]
for event in data["events"]:
block = int(event["block_number"])
if not 0 <= snapshot["block"] - block <= MAX_TIMESTAMP_AGE_BLOCKS:
continue
args = event.get("args") or []
if (event.get("pallet") != "SubtensorModule" or
event.get("method") != "TimelockedWeightsRevealed" or len(args) != 2):
continue
netuid = args[0]
if isinstance(netuid, (list, tuple)) and len(netuid) == 1:
netuid = netuid[0]
if netuid != snapshot["netuid"] or args[1] not in rows or block in checked:
continue
checked.add(block)
# A locator cannot fabricate a reveal or substitute index ingestion
# time for the actual chain timestamp. Decode all reveals in this block.
events = subtensor.substrate.get_events(block_hash=subtensor.get_block_hash(block))
verified = []
for record in events:
raw = record.get("event", record)
values = raw.get("attributes") or []
if (raw.get("module_id") == "SubtensorModule" and
raw.get("event_id") == "TimelockedWeightsRevealed" and
len(values) == 2 and values[0] == snapshot["netuid"] and values[1] in rows):
verified.append(values[1])
if verified:
at = subtensor.get_timestamp(block=block).isoformat()
for key in verified:
if block > (rows[key].get("revealed_block") or 0):
rows[key].update(revealed_block=block, revealed_at=at)
publish(snapshot)
if all(rows[key].get("revealed_block") for key in active):
break
cursor = data.get("next_cursor")
if not cursor or cursor in cursors:
break
cursors.add(cursor)
params["cursor"] = cursor
except Exception:
snapshot["reveal_note"] = "Reveal lookup incomplete/unavailable; verified events retained. Unknown ≠ not revealed."
publish(snapshot)


def _last_snapshot(output):
if isinstance(output, bytes):
output = output.decode("utf-8", errors="replace")
for line in reversed((output or "").splitlines()):
if line.startswith(PREFIX):
try:
return json.loads(line[len(PREFIX):])
except (ValueError, TypeError):
continue
return None


def fetch_generator_chain(hotkey, *, network="finney", netuid=34, timeout=30):
"""Return unknown/partial on failure; never turn an RPC failure into zero."""
base = dict(status="unavailable", network=network, netuid=netuid,
incentive=None, validators=[])
try:
result = subprocess.run(
[sys.executable, "-m", __name__, hotkey, network, str(netuid)],
capture_output=True, text=True, timeout=timeout, check=False,
)
snapshot = _last_snapshot(result.stdout)
if snapshot is not None:
return snapshot
except subprocess.TimeoutExpired as exc:
snapshot = _last_snapshot(exc.stdout)
if snapshot is not None:
snapshot.update(status="partial", warning="Chain lookup timed out; partial snapshot shown.")
return snapshot
base["warning"] = "Chain lookup timed out; incentive is unknown, not zero."
return base
except OSError:
pass
base["warning"] = "Chain lookup unavailable; incentive is unknown, not zero."
return base


def collect_snapshot(subtensor, hotkey, netuid, network, publish=lambda snapshot: None):
"""All values are read at one metagraph block; timestamps come from chain."""
graph = subtensor.metagraph(netuid, lite=True)
block = int(graph.block)
snapshot = dict(status="partial", network=network, netuid=netuid, block=block,
incentive=None, validators=[], as_of=None)
if hotkey not in graph.hotkeys:
snapshot.update(status="not_registered", warning="Hotkey is not registered on this subnet.")
return snapshot
uid = graph.hotkeys.index(hotkey)
snapshot.update(uid=uid, incentive=float(graph.I[uid]))
publish(snapshot)
try:
snapshot["as_of"] = subtensor.get_timestamp(block=block).isoformat()
snapshot["commit_reveal_enabled"] = bool(subtensor.commit_reveal_enabled(netuid, block=block))
weights = dict(subtensor.weights(netuid, block=block))
permitted = [i for i, permit in enumerate(graph.validator_permit) if bool(permit)]
snapshot["validator_count"] = len(permitted)
snapshot["positive_weight_count"] = sum(
any(int(target) == uid and int(value) > 0 for target, value in weights.get(i, []))
for i in permitted)
chosen = sorted(permitted, key=lambda i: float(graph.S[i]), reverse=True)[:MAX_VALIDATORS]
for i in chosen:
entries = weights.get(i, [])
total = sum(int(value) for _, value in entries)
raw_weight = sum(int(value) for target, value in entries if int(target) == uid)
last = int(graph.last_update[i])
snapshot["validators"].append(dict(
uid=i, hotkey=graph.hotkeys[i],
weight=raw_weight / total if total else 0.0,
submitted_block=last if 0 < last <= block else None,
submitted_at=None,
timing_note=(f">{MAX_TIMESTAMP_AGE_BLOCKS} blocks ago"
if last > 0 and block - last > MAX_TIMESTAMP_AGE_BLOCKS else None),
))
publish(snapshot)
timestamps = {}
for validator in snapshot["validators"]:
last = validator["submitted_block"]
if last and block - last <= MAX_TIMESTAMP_AGE_BLOCKS:
if last not in timestamps:
timestamps[last] = subtensor.get_timestamp(block=last).isoformat()
validator["submitted_at"] = timestamps[last]
publish(snapshot)
snapshot["status"] = "ok"
except Exception:
snapshot["warning"] = "Some chain details are unavailable; missing timestamps are not estimated."
return snapshot


def _main():
import bittensor as bt
hotkey, network, netuid = sys.argv[1:]

def publish(snapshot):
# Child-process protocol, not user-facing logging.
sys.stdout.write(PREFIX + json.dumps(snapshot, allow_nan=False) + "\n")
sys.stdout.flush()

chain = None
try:
chain = bt.Subtensor(network=network, retry_forever=False)
snapshot = collect_snapshot(chain, hotkey, int(netuid), network, publish)
publish(snapshot)
add_recent_reveals(chain, snapshot, publish)
publish(snapshot)
except Exception:
publish(dict(status="unavailable", network=network, netuid=int(netuid),
incentive=None, validators=[], warning="Chain lookup unavailable; incentive is unknown, not zero."))
finally:
if chain is not None:
chain.close()


if __name__ == "__main__":
_main()
Loading
Loading