diff --git a/VERSION b/VERSION index c01c4133..fa423416 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.9.3 +4.9.4 diff --git a/gas/__init__.py b/gas/__init__.py index c1da49ed..1c13c99b 100644 --- a/gas/__init__.py +++ b/gas/__init__.py @@ -1,4 +1,4 @@ -__version__ = "4.9.3" +__version__ = "4.9.4" version_split = __version__.split(".") __spec_version__ = ( diff --git a/gas/cache/content_manager.py b/gas/cache/content_manager.py index ca77751a..813ed24c 100644 --- a/gas/cache/content_manager.py +++ b/gas/cache/content_manager.py @@ -216,6 +216,17 @@ def write_miner_media( return None resolution, file_size = extract_media_info(save_path, modality) + + # Audio presence feeds resolution/audio-tiered reward pricing. + has_audio = None + if modality == Modality.VIDEO: + try: + from gas.cache.util.video import get_video_metadata + + has_audio = bool(get_video_metadata(save_path).get("has_audio")) + except Exception as e: + bt.logging.warning(f"Could not detect audio track in {save_path}: {e}") + media_id = self.media.add_media_entry( prompt_id=prompt_id, file_path=save_path, @@ -234,6 +245,7 @@ def write_miner_media( c2pa_verified=c2pa_verified, c2pa_issuer=c2pa_issuer, task_id=task_id, + has_audio=has_audio, ) self.challenges.update_outcome( task_id=task_id, @@ -611,6 +623,7 @@ def record_challenge_outcome( failure_reason: Optional[str] = None, media_id: Optional[str] = None, created_at: Optional[float] = None, + requested_resolution: Optional[str] = None, ) -> bool: return self.challenges.record_outcome( task_id=task_id, @@ -622,6 +635,7 @@ def record_challenge_outcome( failure_reason=failure_reason, media_id=media_id, created_at=created_at, + requested_resolution=requested_resolution, ) def update_challenge_outcome( diff --git a/gas/cache/db/challenge_store.py b/gas/cache/db/challenge_store.py index 7ba999d6..b18ac2a0 100644 --- a/gas/cache/db/challenge_store.py +++ b/gas/cache/db/challenge_store.py @@ -1,8 +1,9 @@ """ChallengeStore — CRUD and reward stats for the generator_challenge_outcomes table.""" +import json import sqlite3 import time -from typing import List, Dict, Optional, Any +from typing import List, Dict, Optional, Any, Tuple import bittensor as bt @@ -10,6 +11,17 @@ from gas.cache.db.connection import ConnectionManager +def _parse_resolution(raw: Optional[str]) -> Optional[Tuple[int, int]]: + """Parse the media table's JSON-encoded (width, height) resolution.""" + if not raw: + return None + try: + data = json.loads(raw) + return (int(data[0]), int(data[1])) + except (ValueError, TypeError, IndexError, json.JSONDecodeError): + return None + + class ChallengeStore: """Data access for the ``generator_challenge_outcomes`` table.""" @@ -31,6 +43,7 @@ def record_outcome( failure_reason: Optional[str] = None, media_id: Optional[str] = None, created_at: Optional[float] = None, + requested_resolution: Optional[str] = None, ) -> bool: """Insert or update a generation challenge outcome.""" try: @@ -41,9 +54,9 @@ def record_outcome( """ INSERT INTO generator_challenge_outcomes ( task_id, uid, hotkey, prompt_id, modality, status, - failure_reason, media_id, created_at, updated_at + failure_reason, media_id, requested_resolution, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(task_id) DO UPDATE SET uid = excluded.uid, hotkey = excluded.hotkey, @@ -52,9 +65,10 @@ def record_outcome( status = excluded.status, failure_reason = excluded.failure_reason, media_id = COALESCE(excluded.media_id, generator_challenge_outcomes.media_id), + requested_resolution = COALESCE(excluded.requested_resolution, generator_challenge_outcomes.requested_resolution), updated_at = excluded.updated_at """, - (task_id, uid, hotkey, prompt_id, modality, status, failure_reason, media_id, created_at, now), + (task_id, uid, hotkey, prompt_id, modality, status, failure_reason, media_id, requested_resolution, created_at, now), ) conn.commit() return True @@ -144,9 +158,11 @@ def get_outcomes_last_n_hours( conn.row_factory = sqlite3.Row cursor = conn.execute( """ - SELECT * FROM generator_challenge_outcomes - WHERE status IN ('verified', 'failed') AND updated_at >= ? - ORDER BY updated_at DESC LIMIT ? + SELECT o.*, m.resolution AS media_resolution, m.has_audio AS media_has_audio + FROM generator_challenge_outcomes o + LEFT JOIN media m ON o.media_id = m.id + WHERE o.status IN ('verified', 'failed') AND o.updated_at >= ? + ORDER BY o.updated_at DESC LIMIT ? """, (cutoff, int(limit)), ) @@ -161,6 +177,13 @@ def get_outcomes_last_n_hours( failure_reason=row["failure_reason"], media_id=row["media_id"], model_name=row["model_name"] if "model_name" in row.keys() else None, + requested_resolution=( + row["requested_resolution"] if "requested_resolution" in row.keys() else None + ), + observed_resolution=_parse_resolution(row["media_resolution"]), + has_audio=( + bool(row["media_has_audio"]) if row["media_has_audio"] is not None else None + ), created_at=row["created_at"], updated_at=row["updated_at"], ) @@ -186,6 +209,7 @@ def get_outcome_stats_last_n_hours( "image_verified": 0, "video_verified": 0, "image_failed": 0, "video_failed": 0, "image_model_names": [], "video_model_names": [], + "image_generations": [], "video_generations": [], "last_timestamp": outcome.updated_at, } modality = (outcome.modality or "").lower() @@ -195,10 +219,27 @@ def get_outcome_stats_last_n_hours( miner_stats[hotkey]["image_verified"] += 1 if outcome.model_name: miner_stats[hotkey]["image_model_names"].append(outcome.model_name) + miner_stats[hotkey]["image_generations"].append({ + "model_name": outcome.model_name, + "requested_resolution": outcome.requested_resolution, + "observed_resolution": ( + list(outcome.observed_resolution) + if outcome.observed_resolution else None + ), + }) elif modality == "video": miner_stats[hotkey]["video_verified"] += 1 if outcome.model_name: miner_stats[hotkey]["video_model_names"].append(outcome.model_name) + miner_stats[hotkey]["video_generations"].append({ + "model_name": outcome.model_name, + "requested_resolution": outcome.requested_resolution, + "observed_resolution": ( + list(outcome.observed_resolution) + if outcome.observed_resolution else None + ), + "has_audio": outcome.has_audio, + }) if outcome.media_id: miner_stats[hotkey]["verified_media_ids"].append(outcome.media_id) elif outcome.status == "failed": @@ -229,10 +270,12 @@ def get_outcome_stats_last_n_hours( "image_failed": img_f, "image_pass_rate": (img_v / img_t) if img_t > 0 else 0.0, "image_model_names": stats.get("image_model_names", []), + "image_generations": stats.get("image_generations", []), "video_verified": vid_v, "video_failed": vid_f, "video_pass_rate": (vid_v / vid_t) if vid_t > 0 else 0.0, "video_model_names": stats.get("video_model_names", []), + "video_generations": stats.get("video_generations", []), "media_ids": stats["verified_media_ids"], "last_timestamp": stats["last_timestamp"], } diff --git a/gas/cache/db/media_store.py b/gas/cache/db/media_store.py index 7b6bc589..dfce7843 100644 --- a/gas/cache/db/media_store.py +++ b/gas/cache/db/media_store.py @@ -67,6 +67,7 @@ def _row_to_media_entry(row) -> MediaEntry: perceptual_hash=row["perceptual_hash"] if "perceptual_hash" in row.keys() else None, c2pa_verified=bool(row["c2pa_verified"]) if "c2pa_verified" in row.keys() and row["c2pa_verified"] is not None else False, c2pa_issuer=row["c2pa_issuer"] if "c2pa_issuer" in row.keys() else None, + has_audio=bool(row["has_audio"]) if "has_audio" in row.keys() and row["has_audio"] is not None else None, ) # ------------------------------------------------------------------ @@ -125,6 +126,7 @@ def add_media_entry( c2pa_verified: Optional[bool] = None, c2pa_issuer: Optional[str] = None, task_id: Optional[str] = None, + has_audio: Optional[bool] = None, ) -> str: """Add a media entry to the database. Returns the media id.""" media_id = str(uuid.uuid4()) @@ -139,9 +141,9 @@ def add_media_entry( model_name, generation_args, uid, hotkey, verified, failed_verification, rewarded, created_at, mask_path, timestamp, resolution, file_size, format, - perceptual_hash, c2pa_verified, c2pa_issuer, task_id + perceptual_hash, c2pa_verified, c2pa_issuer, task_id, has_audio ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( media_id, prompt_id, file_path, modality.value, media_type.value, source_type.value, @@ -155,6 +157,7 @@ def add_media_entry( 1 if c2pa_verified else 0 if c2pa_verified is not None else None, c2pa_issuer, task_id, + 1 if has_audio else 0 if has_audio is not None else None, ), ) conn.commit() diff --git a/gas/cache/db/migrations.py b/gas/cache/db/migrations.py index e6c4cab0..97bc71ec 100644 --- a/gas/cache/db/migrations.py +++ b/gas/cache/db/migrations.py @@ -60,6 +60,13 @@ "ALTER TABLE media ADD COLUMN clip_embedding BLOB", ], ), + ( + "add_resolution_tier_columns", + [ + "ALTER TABLE generator_challenge_outcomes ADD COLUMN requested_resolution TEXT", + "ALTER TABLE media ADD COLUMN has_audio BOOLEAN", + ], + ), ( "add_prompt_spec_columns", [ diff --git a/gas/cache/types.py b/gas/cache/types.py index 69af6c5a..cf44317e 100644 --- a/gas/cache/types.py +++ b/gas/cache/types.py @@ -86,6 +86,9 @@ class MediaEntry: c2pa_verified: Optional[bool] = False # C2PA validation passed c2pa_issuer: Optional[str] = None # Issuer name if C2PA verified + # Video audio track presence (None = unknown / not applicable) + has_audio: Optional[bool] = None + # Miner challenge tracking task_id: Optional[str] = None @@ -141,6 +144,9 @@ class ChallengeOutcome: failure_reason: Optional[str] = None media_id: Optional[str] = None model_name: Optional[str] = None + requested_resolution: Optional[str] = None # tier requested in the challenge (e.g. "1080p") + observed_resolution: Optional[tuple] = None # (width, height) of the stored media + has_audio: Optional[bool] = None # audio track present in the stored media created_at: float = None updated_at: float = None diff --git a/gas/evaluation/generative_challenge_manager.py b/gas/evaluation/generative_challenge_manager.py index 2bb9720f..bb554c66 100644 --- a/gas/evaluation/generative_challenge_manager.py +++ b/gas/evaluation/generative_challenge_manager.py @@ -21,6 +21,7 @@ from typing import Dict, Optional from gas.cache.content_manager import ContentManager +from gas.evaluation.resolution_tiers import sample_challenge_tier from gas.protocol.epistula import get_verifier from gas.protocol.validator_requests import query_generative_miner from gas.types import MediaType, MinerType, Modality @@ -141,7 +142,12 @@ async def issue_generative_challenge(self): async def send_generative_request(self, uid: int, prompt_entry, modality: Modality): """Scoring is handled by the callback in GeneratorEvaluator""" - #parameters = {"width": 1024, "height": 1024} + # Challenges request a resolution tier (video: 480p/720p/1080p, + # image: 1K/2K/4K); rewards are priced at min(observed, requested) so + # overshooting never pays more and miners whose model can't reach the + # tier degrade to a lower price, not failure. + requested_resolution = sample_challenge_tier(modality.value) + parameters = {"resolution": requested_resolution} async with aiohttp.ClientSession() as session: response_data = await query_generative_miner( @@ -152,7 +158,7 @@ async def send_generative_request(self, uid: int, prompt_entry, modality: Modali prompt=prompt_entry.content, modality=modality, webhook_url=self.generative_callback_url, - parameters=None, + parameters=parameters, total_timeout=self.config.neuron.miner_total_timeout, ) @@ -169,6 +175,7 @@ async def send_generative_request(self, uid: int, prompt_entry, modality: Modali "media_type": MediaType.SYNTHETIC, "status": "pending", "sent_at": time.time(), + "requested_resolution": requested_resolution, } self.content_manager.record_challenge_outcome( task_id=miner_task_id, @@ -177,6 +184,7 @@ async def send_generative_request(self, uid: int, prompt_entry, modality: Modali prompt_id=prompt_entry.id, modality=modality.value, status="pending", + requested_resolution=requested_resolution, ) bt.logging.info( f"Stored challenge task {miner_task_id} for UID {uid}. Total active tasks: {len(self.challenge_tasks)}" diff --git a/gas/evaluation/resolution_tiers.py b/gas/evaluation/resolution_tiers.py new file mode 100644 index 00000000..fce3e452 --- /dev/null +++ b/gas/evaluation/resolution_tiers.py @@ -0,0 +1,127 @@ +""" +Resolution tiers for generation challenges. + +Validators request a tier per challenge and reward at the tier actually +delivered, capped at the tier requested: reward_tier = min(observed, requested). +Overshooting a request never pays more, and undershooting (model can't reach +the requested tier) degrades gracefully to the lower tier's price instead of +failing the challenge. + +Video tiers ("480p", "720p", "1080p") match the OpenRouter /videos API +resolution values that the reference miner forwards to providers. 4K video is +deliberately not a tier for now: it is never requested, and 4K output +classifies (and prices) as 1080p. + +Image tiers ("1K", "2K", "4K") refer to the minimum pixel dimension +(nominal 1024 / 2048 / 4096). +""" + +import random +from typing import Dict, List, Optional, Tuple + +VIDEO_MODALITY = "video" +IMAGE_MODALITY = "image" + +# Lowest to highest, per modality. +TIER_ORDER: Dict[str, List[str]] = { + VIDEO_MODALITY: ["480p", "720p", "1080p"], + IMAGE_MODALITY: ["1K", "2K", "4K"], +} + +_TIER_RANK: Dict[str, Dict[str, int]] = { + modality: {tier: rank for rank, tier in enumerate(order)} + for modality, order in TIER_ORDER.items() +} + +# Weighted distributions for sampling the requested tier of a challenge. +# The knob for dataset resolution mix and how often top-tier capability is +# probed (top tiers are the most expensive for miners to serve). +CHALLENGE_TIER_WEIGHTS: Dict[str, Dict[str, float]] = { + VIDEO_MODALITY: { + "480p": 0.20, + "720p": 0.40, + "1080p": 0.40, + }, + IMAGE_MODALITY: { + "1K": 0.40, + "2K": 0.40, + "4K": 0.20, + }, +} + +# Classification thresholds on the *minimum* dimension so portrait and +# landscape orientations classify identically (e.g. 720x1280 and 1280x720 are +# both 720p). Thresholds sit below nominal values to tolerate provider +# variations like 704 or 2156-pixel encodes. The lowest tier is a catch-all +# floor for undersized output. +_TIER_MIN_DIM_THRESHOLDS: Dict[str, List[Tuple[str, int]]] = { + VIDEO_MODALITY: [ + ("1080p", 1000), # nominal 1080; also absorbs 4K output (no 4K tier yet) + ("720p", 620), # nominal 720 + ("480p", 0), + ], + IMAGE_MODALITY: [ + ("4K", 3500), # nominal 3840/4096 + ("2K", 1900), # nominal 2048 + ("1K", 0), # nominal 1024; catch-all for smaller output + ], +} + + +def sample_challenge_tier(modality: str = VIDEO_MODALITY, rng: random.Random = random) -> str: + """Sample a requested resolution tier from the modality's weight distribution.""" + weights_table = CHALLENGE_TIER_WEIGHTS[modality] + tiers = list(weights_table.keys()) + weights = list(weights_table.values()) + return rng.choices(tiers, weights=weights, k=1)[0] + + +def normalize_tier(tier: Optional[str], modality: str = VIDEO_MODALITY) -> Optional[str]: + """Map a tier string to its canonical form ("4k" -> "4K"), or None if unknown.""" + if not tier: + return None + for canonical in TIER_ORDER[modality]: + if canonical.lower() == tier.strip().lower(): + return canonical + return None + + +def tier_from_resolution( + resolution: Optional[Tuple[int, int]], modality: str = VIDEO_MODALITY +) -> Optional[str]: + """Classify observed (width, height) pixel dimensions into a tier.""" + if not resolution or len(resolution) != 2: + return None + try: + min_dim = min(int(resolution[0]), int(resolution[1])) + except (TypeError, ValueError): + return None + if min_dim <= 0: + return None + for tier, threshold in _TIER_MIN_DIM_THRESHOLDS[modality]: + if min_dim >= threshold: + return tier + return None + + +def effective_tier( + observed_resolution: Optional[Tuple[int, int]], + requested_tier: Optional[str], + modality: str = VIDEO_MODALITY, +) -> Optional[str]: + """ + Tier a generation is priced at: min(observed, requested). + + Returns None when the observed resolution is unknown — pricing then falls + back to the baseline, since an unverifiable resolution earns no premium. + A missing requested tier (e.g. outcomes recorded before tiered challenges + shipped) prices at the observed tier alone. + """ + observed = tier_from_resolution(observed_resolution, modality) + if observed is None: + return None + requested = normalize_tier(requested_tier, modality) + if requested is None: + return observed + rank = _TIER_RANK[modality] + return observed if rank[observed] <= rank[requested] else requested diff --git a/gas/evaluation/rewards.py b/gas/evaluation/rewards.py index 9581f4f4..1be1d32f 100644 --- a/gas/evaluation/rewards.py +++ b/gas/evaluation/rewards.py @@ -1,9 +1,11 @@ import math import time -from typing import Dict, Optional +from typing import Any, Dict, List, Optional, Tuple import bittensor as bt +from gas.evaluation.resolution_tiers import effective_tier + # Model generation cost in USD per second of video (720p, no audio unless noted). # Used to compute reward multipliers: miner gets baseline_ratio * (model_price / baseline_price). # @@ -13,26 +15,124 @@ # # C2PA blindness note: several providers do NOT expose the model variant in their # C2PA manifests. All Veo (any provider) returns None. All Runway proprietary -# models share the same "RunwayML" softwareAgent. We conservatively use the -# cheapest variant as baseline and accept the ambiguity in the reward multiplier. +# models share the same "RunwayML" softwareAgent. Veo videos are priced by a +# resolution/audio floor (see MODEL_TIER_PRICES); other unknowns get the baseline. GENERATOR_MODEL_PRICES: Dict[str, float] = { - # Google Veo family (C2PA: no variant exposed — all return None → baseline) + # Google Veo family (C2PA: no variant exposed — all return None → tier floor) "google/veo-3.1-lite": 0.03, # cheapest Veo — used as baseline "google/veo-3.1-fast": 0.08, "google/veo-3.1": 0.20, # ByteDance Seedance (C2PA: params.model_name — variant IS exposed) - # Pricing is per-token; figures are 720p-with-audio references (USD/s). + # 720p reference (USD/s); per-tier prices in NAMED_MODEL_TIER_PRICES take + # precedence — these flat values only serve the legacy model-name path. # Covers both OpenRouter (bytedance/*) and Runway (seedance2/seedance2_fast) — # both route through ByteDance's own C2PA signing (sig issuer: Byteplus Pte. Ltd.). # Confirmed via live test: seedance-1-5-pro has NO C2PA on OpenRouter and is # not offered on Runway — only the 2.0 variants are validator-eligible. - "dreamina-seedance-2-0-fast": 0.12, # ~$0.121/s at 720p - "dreamina-seedance-2-0": 0.15, # ~$0.151/s at 720p + "dreamina-seedance-2-0-fast": 0.152, # $0.76 per 5s at 720p (provider quote, 2026-07) + "dreamina-seedance-2-0": 0.40, # $2.00 per 5s at 720p (provider quote, 2026-07) # Runway gen4.5: C2PA manifest present but claimSignature.mismatch — validators # reject all gen4.5 content until Runway fixes their signing infra. # "RunwayML": 0.05, # re-enable when gen4.5 signature is fixed } +# Per-(resolution tier, audio) USD/s prices for models whose C2PA manifests +# hide the variant (the Veo family). A Veo video is priced as the floor +# variant (_VEO_FLOOR_MODEL) at its observed (tier, audio) combination. +# Since challenges request a tier and rewards use min(observed, requested), +# overshooting a request never pays more. +# +# Keys mirror OpenRouter pricing_skus: the un-suffixed duration_seconds SKU is +# the 1080p default; _720p/_4k suffixes are explicit tiers. Defaults below +# mirror live values as of 2026-07; live SKUs are merged on top at module load. +# +# 4K entries are inert for now: "4K" is not a video tier (never requested, and +# 4K output classifies as 1080p — see resolution_tiers.py), so no lookup ever +# reaches them. They are kept because the live SKU merge re-adds them anyway, +# and they become active again the moment 4K is added back to the video tiers. +MODEL_TIER_PRICES: Dict[str, Dict[Tuple[str, bool], float]] = { + "google/veo-3.1-lite": { + ("720p", False): 0.03, ("720p", True): 0.05, + ("1080p", False): 0.05, ("1080p", True): 0.08, + }, + "google/veo-3.1-fast": { + ("720p", False): 0.08, ("720p", True): 0.10, + ("1080p", False): 0.10, ("1080p", True): 0.12, + ("4K", False): 0.25, ("4K", True): 0.30, + }, + "google/veo-3.1": { + ("1080p", False): 0.20, ("1080p", True): 0.40, + ("4K", False): 0.40, ("4K", True): 0.60, + }, +} + +# Models priced via the tier floor when C2PA gives model_name=None. +_TIER_FLOOR_FAMILY_PREFIX = "google/veo" + +# The variant a C2PA-blind Veo generation is priced as. Google's manifests +# hide the variant, so some assumption is unavoidable: flooring at lite +# (cheapest) is maximally arbitrage-safe but credits every real fast/full +# generation at lite rates; flooring at fast (the middle variant) bounds a +# lite miner's overcredit at ~2x on price while Google-certified content — +# cryptographic proof of real, paid generation — earns a fair mid-family +# rate. Volume is demand-limited, so this doesn't reorder model choice. +_VEO_FLOOR_MODEL = "google/veo-3.1-fast" + +# Per-tier USD/s prices for named models (C2PA exposes model_name). +# OpenRouter publishes no Seedance pricing SKUs, so these come from provider +# quotes (2026-07, per 5s video): seedance-2-0 $0.50 / $2.00 / $2.40 at +# 480p / 720p / 1080p; seedance-2-0-fast $0.35 / $0.76 at 480p / 720p. +# Note the spread is NOT pixel-proportional (full jumps ~4x from 480p to +# 720p but only ~1.2x from 720p to 1080p), which is why these are explicit +# tables rather than a shared scale. Keys are matched against the C2PA +# model_name the same way as GENERATOR_MODEL_PRICES (exact, then substring). +NAMED_MODEL_TIER_PRICES: Dict[str, Dict[str, float]] = { + "dreamina-seedance-2-0-fast": { + "480p": 0.07, # $0.35 / 5s + "720p": 0.152, # $0.76 / 5s + }, + "dreamina-seedance-2-0": { + "480p": 0.10, # $0.50 / 5s + "720p": 0.40, # $2.00 / 5s + "1080p": 0.48, # $2.40 / 5s + }, +} + +# Fallback resolution scaling for named models with no NAMED_MODEL_TIER_PRICES +# table: their flat 720p reference is scaled by the delivered tier's pixel +# ratio (480p is ~0.44x the 720p pixel count, 1080p ~2.25x). +NAMED_MODEL_TIER_SCALE: Dict[str, float] = { + "480p": 0.44, + "720p": 1.0, + "1080p": 2.25, +} + + +def _get_named_model_tier_table(model_name: str) -> Optional[Dict[str, float]]: + """Per-tier price table for a C2PA model name (exact, then substring match).""" + lower = model_name.lower() + for key, table in NAMED_MODEL_TIER_PRICES.items(): + if key.lower() == lower: + return table + for key, table in NAMED_MODEL_TIER_PRICES.items(): + if key.lower() in lower: + return table + return None + +# Relative per-image prices by resolution tier (1K is the image baseline). +# Image C2PA manifests rarely expose a usable model variant and image APIs +# price per image (often tiered by output size), so images are priced purely +# by delivered resolution tier: min(observed, requested), same as video. +# Ratios approximate the resolution-tier spreads of the trusted image APIs +# (e.g. GPT Image 2 spans ~3x from 1K to 4K); tune here as providers change. +IMAGE_TIER_PRICES: Dict[str, float] = { + "1K": 1.0, + "2K": 2.0, + "4K": 4.0, +} + +_IMAGE_BASELINE_PRICE: float = min(IMAGE_TIER_PRICES.values()) + # Cheapest model price — all multipliers are relative to this. _GENERATOR_BASELINE_PRICE: float = min(GENERATOR_MODEL_PRICES.values()) @@ -41,13 +141,33 @@ _LIVE_PRICES_FETCHED = False -def _fetch_openrouter_prices() -> Dict[str, float]: +def _parse_duration_sku_key(key: str) -> Optional[Tuple[str, bool]]: + """Map a duration_seconds SKU key to a (tier, has_audio) pair. + + "duration_seconds_with_audio" -> ("1080p", True) # no suffix = 1080p default + "duration_seconds_without_audio_720p" -> ("720p", False) + "duration_seconds_with_audio_4k" -> ("4K", True) + """ + if "duration_seconds" not in key: + return None + has_audio = "without_audio" not in key + if key.endswith("_720p"): + tier = "720p" + elif key.endswith("_4k"): + tier = "4K" + else: + tier = "1080p" + return tier, has_audio + + +def _fetch_openrouter_prices() -> Tuple[Dict[str, float], Dict[str, Dict[Tuple[str, bool], float]]]: """Pull live video model prices from OpenRouter /videos/models (free, no auth). - Only runs once per process — result is cached at module level.""" + Returns (flat_prices, tier_prices). Only runs once per process — result is + cached at module level.""" global _LIVE_PRICES_FETCHED if _LIVE_PRICES_FETCHED: - return {} + return {}, {} _LIVE_PRICES_FETCHED = True try: @@ -57,35 +177,45 @@ def _fetch_openrouter_prices() -> Dict[str, float]: timeout=10, ) if resp.status_code != 200: - return {} + return {}, {} models = resp.json().get("data", []) prices: Dict[str, float] = {} + tier_prices: Dict[str, Dict[Tuple[str, bool], float]] = {} for m in models: model_id = m.get("id", "") skus = m.get("pricing_skus", {}) or {} # Find the cheapest per-second price for text-to-video candidates = [] + tier_table: Dict[Tuple[str, bool], float] = {} for key, val in skus.items(): if "text_to_video" not in key and "duration_seconds" not in key: continue try: - candidates.append(float(val)) + price = float(val) except (ValueError, TypeError): - pass + continue + candidates.append(price) + tier_key = _parse_duration_sku_key(key) + if tier_key: + tier_table[tier_key] = price if candidates: prices[model_id] = min(candidates) + if tier_table: + tier_prices[model_id] = tier_table bt.logging.info(f"Fetched {len(prices)} live OpenRouter video model prices") - return prices + return prices, tier_prices except Exception as e: bt.logging.debug(f"Could not fetch OpenRouter prices: {e}") - return {} + return {}, {} # Merge live prices on top of defaults (live wins over hardcoded for same key). -_live = _fetch_openrouter_prices() +_live, _live_tiers = _fetch_openrouter_prices() if _live: GENERATOR_MODEL_PRICES = {**GENERATOR_MODEL_PRICES, **_live} _GENERATOR_BASELINE_PRICE = min(GENERATOR_MODEL_PRICES.values()) +if _live_tiers: + MODEL_TIER_PRICES = {**MODEL_TIER_PRICES, **_live_tiers} def _get_model_price(model_name: str) -> float: @@ -118,6 +248,105 @@ def _compute_average_model_multiplier(model_names: list[str]) -> float: return total / len(model_names) +def _get_video_generation_price(generation: Dict[str, Any]) -> float: + """USD/s price for one verified video generation. + + In both branches the tier is min(observed, requested) so overshooting a + challenge request never pays, and an unknown observed resolution earns + only the baseline — no premium without a verifiable resolution. + + Named models (Seedance et al. expose model_name via C2PA) use their + per-tier quoted prices (NAMED_MODEL_TIER_PRICES), falling back to + pixel-ratio scaling of their flat 720p reference. C2PA-blind + generations (model_name=None — the Veo family) are priced as the floor + variant (_VEO_FLOOR_MODEL) at the video's (tier, audio) combination; + tiers below the family's minimum output price at its cheapest + producible combo. + """ + tier = effective_tier( + generation.get("observed_resolution"), + generation.get("requested_resolution"), + ) + model_name = generation.get("model_name") + if model_name: + if tier is None: + return _GENERATOR_BASELINE_PRICE + table = _get_named_model_tier_table(model_name) + if table and tier in table: + price = table[tier] + elif table: + # Tier absent from the table (e.g. a fast variant somehow observed + # at 1080p): pay the highest tier the table does price — the model + # can't officially produce more, so no extrapolated premium. + price = max(table.values()) + else: + price = _get_model_price(model_name) * NAMED_MODEL_TIER_SCALE.get(tier, 1.0) + # Baseline is the floor: a named model unknown to the price table + # resolves to the baseline price, and tier down-scaling must not push + # it (or any cheap model) below what an unpriceable generation earns. + return max(price, _GENERATOR_BASELINE_PRICE) + + if tier is None: + return _GENERATOR_BASELINE_PRICE + + has_audio = bool(generation.get("has_audio")) + floor_table = MODEL_TIER_PRICES.get(_VEO_FLOOR_MODEL, {}) + price = floor_table.get((tier, has_audio)) + if price is None: + # Tier below the family's minimum output (Veo can't produce sub-720p, + # so a 480p-tier request still costs the miner a real 720p + # generation): match Seedance fast's 480p rate so the bottom tier + # pays the same regardless of family. + price = NAMED_MODEL_TIER_PRICES["dreamina-seedance-2-0-fast"]["480p"] + return max(price, _GENERATOR_BASELINE_PRICE) + + +def _compute_video_generation_multiplier(generations: List[Dict[str, Any]]) -> float: + """Average price multiplier across a miner's verified video generations. + + Same sqrt taper as _compute_average_model_multiplier, but priced per + generation from (model_name, resolution tier, audio) instead of model + name alone. + """ + if not generations: + return 1.0 + total = 0.0 + for generation in generations: + price = _get_video_generation_price(generation) + total += math.sqrt(price / _GENERATOR_BASELINE_PRICE) + return total / len(generations) + + +def _get_image_generation_price(generation: Dict[str, Any]) -> float: + """Relative price for one verified image generation, by delivered tier. + + Tier is min(observed, requested) so overshooting a challenge request never + pays more. An unknown observed resolution earns only the 1K baseline. + """ + tier = effective_tier( + generation.get("observed_resolution"), + generation.get("requested_resolution"), + modality="image", + ) + if tier is None: + return _IMAGE_BASELINE_PRICE + return IMAGE_TIER_PRICES.get(tier, _IMAGE_BASELINE_PRICE) + + +def _compute_image_generation_multiplier(generations: List[Dict[str, Any]]) -> float: + """Average resolution-tier multiplier across a miner's verified images. + + Same sqrt taper as video: a tier 4x the baseline price earns 2x, not 4x. + """ + if not generations: + return 1.0 + total = 0.0 + for generation in generations: + price = _get_image_generation_price(generation) + total += math.sqrt(price / _IMAGE_BASELINE_PRICE) + return total / len(generations) + + def get_generator_base_rewards(verification_stats): """ Compute base rewards for generators based on their verification pass rates, @@ -142,6 +371,8 @@ def get_generator_base_rewards(verification_stats): "video_failed": int, "video_pass_rate": float, "video_model_names": list[str], + "image_generations": list[dict], # per-generation resolution tiers + "video_generations": list[dict], # per-generation model/resolution/audio "media_ids": List[str] } } @@ -167,9 +398,16 @@ def get_generator_base_rewards(verification_stats): image_pass_rate = stats.get("image_pass_rate", 0.0) image_volume = min(image_verified, 10) + max(0.0, math.log2(max(1, image_verified - 9))) image_base = image_pass_rate * image_volume - image_model_mult = _compute_average_model_multiplier( - stats.get("image_model_names", []) - ) + # Prefer per-generation resolution-tier pricing; fall back to model + # names for stats produced before tiered pricing (all baseline today, + # since no image models carry a flat price). + image_generations = stats.get("image_generations") + if image_generations: + image_model_mult = _compute_image_generation_multiplier(image_generations) + else: + image_model_mult = _compute_average_model_multiplier( + stats.get("image_model_names", []) + ) image_base *= image_model_mult # --- Video modality --- @@ -177,9 +415,15 @@ def get_generator_base_rewards(verification_stats): video_pass_rate = stats.get("video_pass_rate", 0.0) video_volume = min(video_verified, 10) + max(0.0, math.log2(max(1, video_verified - 9))) video_base = video_pass_rate * video_volume - video_model_mult = _compute_average_model_multiplier( - stats.get("video_model_names", []) - ) + # Prefer per-generation (model, resolution tier, audio) pricing; + # fall back to model names for stats produced before tiered pricing. + video_generations = stats.get("video_generations") + if video_generations: + video_model_mult = _compute_video_generation_multiplier(video_generations) + else: + video_model_mult = _compute_average_model_multiplier( + stats.get("video_model_names", []) + ) video_base *= video_model_mult uid_rewards[uid] = {"image": image_base, "video": video_base}