diff --git a/README.md b/README.md index 183805c..5f95b74 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,13 @@ Nothing a miner claims is used. The artifact is fetched from Hugging Face at the ## How an epoch works -1. **Commit.** A miner pays the arena fee, if the arena charges one, and commits one line on chain: arena, Hugging Face user, artifact commit, fee block and extrinsic index (0 and 0 for a free arena). The operator sets each arena's fee and can change it at any time; each epoch's roster records the fee it ran under. -2. **Roster and snapshot.** At commit close the server verifies every fee it charges and publishes a signed roster and a signed snapshot of previous bests. -3. **Release.** Once the open block is final the server seals the epoch's encrypted item bundle to every permitted validator and publishes the seed, the hash of the open block hash and the arena id. -4. **Evaluation.** Each validator serves the miner's model with vLLM, serves the miner's router, runs the miner's `main.py` in the sandbox, and runs the SLM, harness, router, escalation and system tests. -5. **Report.** Each validator signs one report per epoch and sets weights from its own scores. -6. **Ledger and archive.** The server publishes the stake weighted median across agreeing reports, applies the ladder, and mirrors the SLM pool winner privately to the archive. +1. **Commit, sealed.** A miner pays the arena fee, if the arena charges one, and commits one line on chain: arena, a digest of (Hugging Face user, artifact commit, secret salt), fee block and extrinsic index (0 and 0 for a free arena). The repositories stay private, so nothing can be copied during the window. `mt submit commit --open` still publishes the repository in the clear. +2. **Reveal.** When the commit window closes a reveal window opens. The miner makes both repositories public and runs `mt submit reveal`, which sends the user, commit and salt signed by the hotkey. The server checks the digest; a missing or mismatched reveal is rejected, and validators recheck the digest themselves from the roster. A model or artifact whose weights duplicate an earlier commitment is rejected as a copy. +3. **Roster and snapshot.** At commit close the server verifies every fee it charges and publishes a signed roster and a signed snapshot of previous bests. +4. **Release.** Once the open block is final the server seals the epoch's encrypted item bundle to every permitted validator and publishes the seed, the hash of the open block hash and the arena id. +5. **Evaluation.** Each validator serves the miner's model with vLLM, serves the miner's router, runs the miner's `main.py` in the sandbox, and runs the SLM, harness, router, escalation and system tests. +6. **Report.** Each validator signs one report per epoch and sets weights from its own scores. +7. **Ledger and archive.** The server publishes the stake weighted median across agreeing reports, applies the ladder, and mirrors the SLM pool winner privately to the archive. Five pools pay 20% each by rank (40 / 25 / 15 / 12 / 8). Every score is a one sided lower bound; a published score only moves when a new result beats the previous best for the same artifact by 0.01. diff --git a/microtensor_subnet/chain/commitment.py b/microtensor_subnet/chain/commitment.py index 3f43cb0..f7b22de 100644 --- a/microtensor_subnet/chain/commitment.py +++ b/microtensor_subnet/chain/commitment.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import re from dataclasses import dataclass from typing import Final @@ -200,12 +201,16 @@ def decode_all(raw_by_hotkey: dict[str, str]) -> dict[str, Commitment]: SUBMISSION_TAG: Final[str] = "mt2" +SEALED_TAG: Final[str] = "mt3" +REVEAL_MESSAGE_TAG: Final[str] = "mt-reveal" ARTIFACT_REPO_PREFIX: Final[str] = "microtensor_" SLM_REPO_PREFIX: Final[str] = "mt_slm_" _ARENA = re.compile(r"^[a-z0-9][a-z0-9_]{0,15}$") _HF_USER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,39}$") _GIT_SHA = re.compile(r"^[0-9a-f]{40}$") +_DIGEST = re.compile(r"^[0-9a-f]{64}$") +_SALT = re.compile(r"^[0-9a-f]{16,64}$") @dataclass(frozen=True, slots=True) @@ -275,6 +280,89 @@ def decode(cls, raw: str) -> SubmissionCommitment | None: return None +def seal_digest(arena_id: str, hf_user: str, artifact_sha: str, salt: str) -> str: + plain = f"{arena_id}|{hf_user}|{artifact_sha.lower()}|{salt}" + return hashlib.sha256(plain.encode()).hexdigest() + + +def reveal_message( + arena_id: str, epoch_index: int, hotkey: str, hf_user: str, artifact_sha: str, salt: str +) -> bytes: + return FIELD_SEPARATOR.join( + ( + REVEAL_MESSAGE_TAG, + arena_id, + str(epoch_index), + hotkey, + hf_user, + artifact_sha.lower(), + salt, + ) + ).encode() + + +@dataclass(frozen=True, slots=True) +class SealedCommitment: + arena_id: str + digest: str + fee_block: int + fee_index: int + + def __post_init__(self) -> None: + if not _ARENA.match(self.arena_id): + raise CommitmentError(f"arena id {self.arena_id!r} is not a valid identifier") + if not _DIGEST.match(self.digest): + raise CommitmentError("the sealed digest must be 64 hex characters") + if self.fee_block < 0 or self.fee_index < 0: + raise CommitmentError("the fee reference must name a block and an extrinsic index") + + @classmethod + def seal( + cls, + arena_id: str, + hf_user: str, + artifact_sha: str, + salt: str, + fee_block: int, + fee_index: int, + ) -> SealedCommitment: + if not _HF_USER.match(hf_user): + raise CommitmentError(f"hugging face user {hf_user!r} is not a valid name") + if not _GIT_SHA.match(artifact_sha.lower()): + raise CommitmentError("the artifact revision must be a 40 hex git commit sha") + if not _SALT.match(salt): + raise CommitmentError("the salt must be 16 to 64 hex characters") + digest = seal_digest(arena_id, hf_user, artifact_sha, salt) + return cls(arena_id, digest, fee_block, fee_index) + + def matches(self, hf_user: str, artifact_sha: str, salt: str) -> bool: + return seal_digest(self.arena_id, hf_user, artifact_sha, salt) == self.digest + + def encode(self) -> str: + return FIELD_SEPARATOR.join( + (SEALED_TAG, self.arena_id, self.digest, str(self.fee_block), str(self.fee_index)) + ) + + @classmethod + def decode(cls, raw: str) -> SealedCommitment | None: + parts = (raw or "").strip().split(FIELD_SEPARATOR) + if len(parts) != 5 or parts[0] != SEALED_TAG: + return None + try: + return cls(parts[1], parts[2].lower(), int(parts[3]), int(parts[4])) + except (CommitmentError, ValueError): + return None + + +def decode_sealed(raw_by_hotkey: dict[str, str]) -> dict[str, SealedCommitment]: + decoded: dict[str, SealedCommitment] = {} + for hotkey, raw in raw_by_hotkey.items(): + commitment = SealedCommitment.decode(raw) + if commitment is not None: + decoded[hotkey] = commitment + return decoded + + def decode_submissions(raw_by_hotkey: dict[str, str]) -> dict[str, SubmissionCommitment]: decoded: dict[str, SubmissionCommitment] = {} for hotkey, raw in raw_by_hotkey.items(): diff --git a/microtensor_subnet/cli/submit.py b/microtensor_subnet/cli/submit.py index 38782d7..b8ecaf8 100644 --- a/microtensor_subnet/cli/submit.py +++ b/microtensor_subnet/cli/submit.py @@ -3,6 +3,7 @@ import argparse import json import logging +import secrets import urllib.error import urllib.parse import urllib.request @@ -20,8 +21,13 @@ write_manifest, ) from microtensor_subnet.chain.client import ChainError -from microtensor_subnet.chain.commitment import CommitmentError, SubmissionCommitment -from microtensor_subnet.chain.wallet import coldkey_address, hotkey_address +from microtensor_subnet.chain.commitment import ( + CommitmentError, + SealedCommitment, + SubmissionCommitment, + reveal_message, +) +from microtensor_subnet.chain.wallet import coldkey_address, hotkey_address, sign_bytes from microtensor_subnet.cli.common import ( add_chain_arguments, add_common_arguments, @@ -76,10 +82,21 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> commit.add_argument("--artifact-sha", required=True, help="artifact repository commit sha") commit.add_argument("--fee-block", type=int, default=0, help="0 when the arena is free") commit.add_argument("--fee-index", type=int, default=0, help="0 when the arena is free") + commit.add_argument( + "--open", action="store_true", help="commit the repository in the open instead of sealed" + ) add_chain_arguments(commit) add_common_arguments(commit) commit.set_defaults(handler=_commit) + reveal = inner.add_parser("reveal", help="reveal a sealed commitment once the window closes") + reveal.add_argument("--arena", required=True) + reveal.add_argument("--epoch", type=int, default=None, help="defaults to the latest epoch") + reveal.add_argument("--server", default=PUBLIC_SERVER_URL) + add_chain_arguments(reveal) + add_common_arguments(reveal) + reveal.set_defaults(handler=_reveal) + status = inner.add_parser("status", help="where the arena and your submission stand") status.add_argument("--arena", required=True) status.add_argument("--server", default=PUBLIC_SERVER_URL) @@ -198,15 +215,42 @@ def _pay(args: argparse.Namespace) -> int: return 0 +def _reveal_path(home: Path, arena_id: str) -> Path: + return Path(home) / "reveals" / f"{arena_id}.json" + + +def _post(base: str, path: str, body: dict[str, Any]) -> Any: + url = base.rstrip("/") + path + if urllib.parse.urlparse(url).scheme not in ("http", "https"): + raise ValueError(f"the server URL must be http or https: {base!r}") + request = urllib.request.Request( # noqa: S310 + url, data=json.dumps(body).encode(), headers={"content-type": "application/json"} + ) + with urllib.request.urlopen(request, timeout=60) as answer: # noqa: S310 + return json.loads(answer.read()) + + def _commit(args: argparse.Namespace) -> int: + artifact_sha = str(args.artifact_sha).lower() + salt = secrets.token_hex(16) try: - commitment = SubmissionCommitment( - arena_id=args.arena, - hf_user=args.hf_user, - artifact_sha=str(args.artifact_sha).lower(), - fee_block=int(args.fee_block), - fee_index=int(args.fee_index), - ) + if args.open: + commitment: SubmissionCommitment | SealedCommitment = SubmissionCommitment( + arena_id=args.arena, + hf_user=args.hf_user, + artifact_sha=artifact_sha, + fee_block=int(args.fee_block), + fee_index=int(args.fee_index), + ) + else: + commitment = SealedCommitment.seal( + args.arena, + args.hf_user, + artifact_sha, + salt, + int(args.fee_block), + int(args.fee_index), + ) payload = commitment.encode() except CommitmentError as exc: return fail(str(exc)) @@ -219,11 +263,77 @@ def _commit(args: argparse.Namespace) -> int: return fail(str(exc)) if not ok: return fail("the chain refused the commitment") + hotkey = hotkey_address(wallet) print(f"committed {payload}") - print(f"hotkey {hotkey_address(wallet)}") + print(f"hotkey {hotkey}") + if isinstance(commitment, SealedCommitment): + path = _reveal_path(args.home, args.arena) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + { + "arena_id": args.arena, + "hotkey": hotkey, + "hf_user": args.hf_user, + "artifact_sha": artifact_sha, + "salt": salt, + "digest": commitment.digest, + }, + indent=2, + ), + encoding="utf-8", + ) + print(f"sealed {commitment.digest}") + print(f"reveal saved to {path}; keep the repositories private until the window closes,") + print(f" then run: mt submit reveal --arena {args.arena}") + else: + print( + f"artifact https://huggingface.co/{commitment.artifact_repo}/tree/{commitment.artifact_sha}" + ) + return 0 + + +def _reveal(args: argparse.Namespace) -> int: + path = _reveal_path(args.home, args.arena) + if not path.is_file(): + return fail(f"no sealed commitment saved at {path}; commit first") + saved = json.loads(path.read_text(encoding="utf-8")) + config = chain_config(args) + wallet = open_wallet(config) + hotkey = hotkey_address(wallet) + if saved.get("hotkey") and saved["hotkey"] != hotkey: + return fail(f"the saved commitment belongs to {saved['hotkey']}, not this hotkey") + epoch = args.epoch + if epoch is None: + try: + epochs = _get(args.server, f"/v2/arenas/{args.arena}/epochs").get("epochs") or [] + except (urllib.error.URLError, ValueError) as exc: + return fail(f"could not read the arena's epochs: {exc}") + if not epochs: + return fail("the arena has no epoch to reveal into yet") + epoch = int(epochs[0]["epoch_index"]) + message = reveal_message( + args.arena, epoch, hotkey, saved["hf_user"], saved["artifact_sha"], saved["salt"] + ) + body = { + "hotkey": hotkey, + "hf_user": saved["hf_user"], + "artifact_sha": saved["artifact_sha"], + "salt": saved["salt"], + "signature": sign_bytes(wallet, message), + } + try: + found = _post(args.server, f"/v2/arenas/{args.arena}/epochs/{epoch}/reveal", body) + except urllib.error.HTTPError as exc: + return fail(f"the server refused the reveal: {exc.read().decode('utf-8', 'replace')[:300]}") + except (urllib.error.URLError, ValueError) as exc: + return fail(f"the reveal could not be sent: {exc}") + print(f"revealed epoch {epoch} {found.get('reveal_state')}") print( - f"artifact https://huggingface.co/{commitment.artifact_repo}/tree/{commitment.artifact_sha}" + f"artifact https://huggingface.co/{found.get('artifact_repo')}/tree/{saved['artifact_sha']}" ) + if found.get("reveal_state") == "unsealed": + print("make both repositories public now; validators fetch them at release") return 0 diff --git a/microtensor_subnet/validator/collect.py b/microtensor_subnet/validator/collect.py index 1626cbd..dd51aea 100644 --- a/microtensor_subnet/validator/collect.py +++ b/microtensor_subnet/validator/collect.py @@ -20,7 +20,12 @@ manifest_sha256, ) from microtensor_subnet.chain.client import ChainError -from microtensor_subnet.chain.commitment import SubmissionCommitment, decode_submissions +from microtensor_subnet.chain.commitment import ( + CommitmentError, + SubmissionCommitment, + decode_sealed, + decode_submissions, +) from microtensor_subnet.validator.context import Artifact log = logging.getLogger("microtensor_subnet.validator.collect") @@ -50,6 +55,23 @@ def candidates( hotkeys = [n.hotkey for n in snapshot.neurons] raw = client.commitments(hotkeys) decoded = decode_submissions(raw) + for hotkey, sealed in decode_sealed(raw).items(): + entry = (roster or {}).get(hotkey) or {} + if str(entry.get("reveal_state") or "") != "unsealed": + log.info("%s is sealed and not revealed on the roster", hotkey) + continue + hf_user = str(entry.get("hf_user") or "") + artifact_sha = str(entry.get("artifact_sha") or "").lower() + salt = str(entry.get("salt") or "") + if not sealed.matches(hf_user, artifact_sha, salt): + log.info("%s revealed values do not match the sealed digest", hotkey) + continue + try: + decoded[hotkey] = SubmissionCommitment( + sealed.arena_id, hf_user, artifact_sha, sealed.fee_block, sealed.fee_index + ) + except CommitmentError as exc: + log.info("%s revealed values are invalid: %s", hotkey, exc) blocks = client.commitment_blocks(list(decoded)) coldkeys = snapshot.coldkeys() if hasattr(snapshot, "coldkeys") else {} found: list[Candidate] = [] diff --git a/microtensor_subnet/validator/epoch.py b/microtensor_subnet/validator/epoch.py index be1832f..fc5624a 100644 --- a/microtensor_subnet/validator/epoch.py +++ b/microtensor_subnet/validator/epoch.py @@ -174,9 +174,32 @@ def gather( ) except collect.CollectError as exc: failures[candidate.hotkey] = str(exc) + for hotkey, reason in duplicates(artifacts).items(): + failures[hotkey] = reason + artifacts.pop(hotkey, None) return artifacts, failures +def duplicates(artifacts: Mapping[str, Artifact]) -> dict[str, str]: + seen_weights: dict[str, str] = {} + seen_manifests: dict[str, str] = {} + found: dict[str, str] = {} + for hotkey, artifact in sorted(artifacts.items(), key=lambda kv: (kv[1].commit_block, kv[0])): + if artifact.manifest_sha in seen_manifests: + first = seen_manifests[artifact.manifest_sha] + found[hotkey] = f"artifact duplicates the earlier commitment by {first}" + continue + weights = provenance.digest_of_dir(artifact.slm_dir) if artifact.slm_dir else "" + if weights and weights in seen_weights: + first = seen_weights[weights] + found[hotkey] = f"model weights duplicate the earlier commitment by {first}" + continue + seen_manifests[artifact.manifest_sha] = hotkey + if weights: + seen_weights[weights] = hotkey + return found + + @dataclass(slots=True) class Runner: config: EpochConfig