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
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
88 changes: 88 additions & 0 deletions microtensor_subnet/chain/commitment.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import hashlib
import re
from dataclasses import dataclass
from typing import Final
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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():
Expand Down
132 changes: 121 additions & 11 deletions microtensor_subnet/cli/submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import argparse
import json
import logging
import secrets
import urllib.error
import urllib.parse
import urllib.request
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand All @@ -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


Expand Down
24 changes: 23 additions & 1 deletion microtensor_subnet/validator/collect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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] = []
Expand Down
23 changes: 23 additions & 0 deletions microtensor_subnet/validator/epoch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading