robotensor-attune 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
robotensor/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Attune, Robotensor's Bittensor subnet: chain commitments in, lane engines' duels, weights out."""
2
+
3
+ __version__ = "0.2.1"
robotensor/chain.py ADDED
@@ -0,0 +1,158 @@
1
+ """The chain, and the only module that imports bittensor.
2
+
3
+ Pinned to bittensor 10.5 (`pyproject.toml`): version 11 replaced the SDK's API, and the subnets this
4
+ one follows (SN3, SN80, SN98) all still pin 10.x. Moving to 11 changes this file and nothing else.
5
+
6
+ Everything here is a thin call to `Subtensor`:
7
+
8
+ - `commitments()`: every hotkey's current commitment on the subnet with the block it was made at,
9
+ from one query of the `Commitments.CommitmentOf` storage map. The chain keeps one commitment per
10
+ hotkey; a new one replaces it and resets its block.
11
+ - `block()`, `block_hash(n)`, `finalized()`, `uids()`: the head, a block's hash, the finalized
12
+ head and its hash, and the metagraph's hotkeys.
13
+ - `commit(wallet, data)` and `set_weights(wallet, weights)`: the two writes.
14
+
15
+ `Subtensor` holds a websocket that is not safe to share between threads, so a `Chain` opens its own
16
+ and a thread that needs the chain makes its own `Chain`.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Mapping
22
+ from dataclasses import dataclass
23
+ from typing import Any
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Commitment:
28
+ hotkey: str
29
+ block: int
30
+ data: str
31
+
32
+
33
+ class ChainError(RuntimeError):
34
+ """The chain refused a write, or could not be read."""
35
+
36
+
37
+ class RateLimited(ChainError):
38
+ """Weights were set too recently: `blocks` more must pass (the subnet's weights rate limit)."""
39
+
40
+ def __init__(self, blocks: int) -> None:
41
+ self.blocks = blocks
42
+ super().__init__(f"weights were set too recently; {blocks} more block(s) must pass")
43
+
44
+
45
+ class Chain:
46
+ def __init__(self, network: str, netuid: int) -> None:
47
+ import bittensor as bt
48
+
49
+ self.network = network
50
+ self.netuid = int(netuid)
51
+ self.subtensor = bt.Subtensor(network=network)
52
+
53
+ # -- reads ------------------------------------------------------------------------------
54
+
55
+ def block(self) -> int:
56
+ return int(self.subtensor.get_current_block())
57
+
58
+ def block_hash(self, block: int) -> str:
59
+ value = self.subtensor.get_block_hash(int(block))
60
+ if not value:
61
+ raise ChainError(f"the chain has no hash for block {block}")
62
+ return str(value)
63
+
64
+ def finalized(self) -> tuple[int, str]:
65
+ """The finalized head: its number and its hash, read together so they name one block."""
66
+ substrate = self.subtensor.substrate
67
+ value = substrate.get_chain_finalised_head()
68
+ if not value:
69
+ raise ChainError("the chain reported no finalized head")
70
+ return int(substrate.get_block_number(value)), str(value)
71
+
72
+ def commitments(self) -> list[Commitment]:
73
+ """Every hotkey's commitment on the subnet, with the block it was made at, oldest first."""
74
+ from bittensor.core.chain_data.utils import decode_metadata
75
+
76
+ out = []
77
+ for hotkey, value in self.subtensor.query_map(
78
+ module="Commitments", name="CommitmentOf", params=[self.netuid]
79
+ ):
80
+ record = getattr(value, "value", value)
81
+ try:
82
+ data = decode_metadata(record)
83
+ block = int(record["block"])
84
+ except Exception: # noqa: BLE001 - a commitment we cannot read is no submission
85
+ continue
86
+ key = getattr(hotkey, "value", hotkey)
87
+ out.append(Commitment(hotkey=str(key), block=block, data=data))
88
+ return sorted(out, key=lambda c: (c.block, c.hotkey))
89
+
90
+ def uids(self) -> dict[str, int]:
91
+ """The metagraph's hotkeys, each with its UID now. UIDs are recycled: map at the end."""
92
+ metagraph = self.subtensor.metagraph(self.netuid, lite=True)
93
+ return {
94
+ str(hk): int(uid) for uid, hk in zip(metagraph.uids, metagraph.hotkeys, strict=True)
95
+ }
96
+
97
+ def owner_hotkey(self) -> str | None:
98
+ return self.subtensor.get_subnet_owner_hotkey(self.netuid)
99
+
100
+ def weights_rate_limit(self) -> int | None:
101
+ return self.subtensor.weights_rate_limit(self.netuid)
102
+
103
+ # -- writes -----------------------------------------------------------------------------
104
+
105
+ def commit(self, wallet: Any, data: str) -> None:
106
+ response = self.subtensor.set_commitment(
107
+ wallet=wallet,
108
+ netuid=self.netuid,
109
+ data=data,
110
+ wait_for_inclusion=True,
111
+ wait_for_finalization=True,
112
+ )
113
+ if not getattr(response, "success", False):
114
+ raise ChainError(f"the chain refused the commitment: {_message(response)}")
115
+
116
+ def set_weights(self, wallet: Any, weights: Mapping[int, float]) -> None:
117
+ """Set `weights` for `wallet`'s hotkey. `RateLimited` while the subnet's weights rate limit
118
+ has not passed since its last update: the SDK then returns a bare failure without trying,
119
+ so the limit is read here first and said plainly."""
120
+ uid = self.subtensor.get_uid_for_hotkey_on_subnet(wallet.hotkey.ss58_address, self.netuid)
121
+ if uid is None:
122
+ raise ChainError(f"{wallet.hotkey.ss58_address} is not registered on {self.netuid}")
123
+ since = self.subtensor.blocks_since_last_update(self.netuid, uid)
124
+ limit = self.subtensor.weights_rate_limit(self.netuid)
125
+ if since is not None and limit is not None and since <= limit:
126
+ raise RateLimited(int(limit) - int(since) + 1)
127
+ uids = sorted(weights)
128
+ response = self.subtensor.set_weights(
129
+ wallet=wallet,
130
+ netuid=self.netuid,
131
+ uids=uids,
132
+ weights=[float(weights[u]) for u in uids],
133
+ wait_for_inclusion=True,
134
+ wait_for_finalization=False,
135
+ )
136
+ if not getattr(response, "success", False):
137
+ raise ChainError(f"the chain refused the weights: {_message(response)}")
138
+
139
+ def close(self) -> None:
140
+ try:
141
+ self.subtensor.close()
142
+ except Exception: # noqa: BLE001 - closing a socket that already went is fine
143
+ pass
144
+
145
+
146
+ def wallet(name: str, hotkey: str, path: str | None = None) -> Any:
147
+ """A bittensor wallet by name, as `btcli` keeps them (`~/.bittensor/wallets`)."""
148
+ import bittensor as bt
149
+
150
+ return (
151
+ bt.Wallet(name=name, hotkey=hotkey, path=path)
152
+ if path
153
+ else bt.Wallet(name=name, hotkey=hotkey)
154
+ )
155
+
156
+
157
+ def _message(response: Any) -> str:
158
+ return str(getattr(response, "message", None) or response)
robotensor/hub.py ADDED
@@ -0,0 +1,266 @@
1
+ """The Hugging Face Hub, as the subnet sees a submission there.
2
+
3
+ Intake decides from the Hub's metadata alone and never downloads a model. Two competitions ask
4
+ different things of a repository, so what each may hold and what makes two submissions the same
5
+ model is a `Shape` the lane brings.
6
+
7
+ **Why metadata is enough.** Git LFS stores a file under its own sha256, and the Hub reports it, so
8
+ the validator can tell a copy from an original without moving a byte. Vector's submission is one
9
+ `model.safetensors` and its content key is that file's sha256 - the value this subnet has always
10
+ used. Horizon's is tens of gigabytes of shards beside a few kilobyte files; its key is the digest
11
+ of the listing `sha256sum` would print for the files it covers, which is what its runtime computes
12
+ on disk. The kilobyte files are not in LFS, so those - and only those - are fetched.
13
+
14
+ A shard that was pushed as a plain git blob rather than through LFS has no sha256 on the Hub and
15
+ is refused, with the remedy: track `*.safetensors` in `.gitattributes`.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import fnmatch
21
+ import hashlib
22
+ from collections.abc import Callable, Iterable, Sequence
23
+ from dataclasses import dataclass, field
24
+ from typing import Any
25
+
26
+ WEIGHTS_FILE = "model.safetensors"
27
+ #: A hashed file the Hub cannot give a sha256 for is fetched when it is no larger than this.
28
+ FETCH_MAX_BYTES = 1 << 20
29
+
30
+
31
+ class NotVisible(RuntimeError):
32
+ """The Hub will not show the repository at that commit (missing, private, or a bad sha)."""
33
+
34
+
35
+ class HubUnreachable(NotVisible):
36
+ """The Hub did not answer (a server error, a timeout, no connection): it said nothing about the
37
+ repository, so nothing may be decided from it. A `NotVisible` to a caller that only waits."""
38
+
39
+
40
+ class NotASubmission(ValueError):
41
+ """The repository holds what this competition's submission may not, or lacks what it must."""
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class HubFile:
46
+ """One file of a repository, as the Hub describes it without downloading anything."""
47
+
48
+ name: str
49
+ size: int
50
+ #: Its own sha256, when Git LFS stores it; `None` for a plain git blob, whose id is a sha1.
51
+ lfs_sha256: str | None = None
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Shape:
56
+ """What a competition's submission may hold, and what makes two of them the same model.
57
+
58
+ `allowed`, `required` and `hashed` are exact names or `fnmatch` globs. `key` is how the
59
+ content key is built from the hashed files:
60
+
61
+ - `"file"`: the one hashed file's own sha256. Vector's, and the value it has always had.
62
+ - `"listing"`: `sha256` of `"<sha256> <name>\\n"` for each hashed file, ordered by the bytes
63
+ of its path - the digest `sha256sum <files> | sha256sum` gives, which is what Horizon's
64
+ runtime computes over the same files on disk.
65
+ """
66
+
67
+ #: Files a repository may hold; one matching none of these is refused.
68
+ allowed: tuple[str, ...]
69
+ #: Patterns at least one file must match, or the repository is not a submission at all.
70
+ required: tuple[str, ...]
71
+ #: The files the content key covers.
72
+ hashed: tuple[str, ...]
73
+ key: str = "file"
74
+ #: The repository's size cap, over the files that count; 0 for none.
75
+ max_bytes: int = 0
76
+ #: Patterns whose files must be in LFS, so their sha256 is known without downloading them.
77
+ lfs_only: tuple[str, ...] = ()
78
+
79
+ def __post_init__(self) -> None:
80
+ if self.key not in ("file", "listing"):
81
+ raise ValueError(f"{self.key!r} is not a content key rule (file, listing)")
82
+
83
+ def matches(self, name: str, patterns: Iterable[str]) -> bool:
84
+ return any(fnmatch.fnmatch(name, pattern) for pattern in patterns)
85
+
86
+
87
+ #: Vector's submission: the weights of one architecture, and nothing else that runs.
88
+ def vector_shape(allowed: Iterable[str], max_bytes: int = 0) -> Shape:
89
+ return Shape(
90
+ allowed=tuple(sorted(allowed)),
91
+ required=(WEIGHTS_FILE,),
92
+ hashed=(WEIGHTS_FILE,),
93
+ key="file",
94
+ max_bytes=max_bytes,
95
+ lfs_only=(WEIGHTS_FILE,),
96
+ )
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class HubSubmission:
101
+ repo: str
102
+ revision: str
103
+ #: What two submissions of the same bytes share, whatever the competition.
104
+ content_key: str
105
+ #: The bytes the hashed files come to.
106
+ weights_bytes: int
107
+ files: tuple[str, ...] = ()
108
+ #: Every file with what the Hub says about it, for a lane that wants more than the names.
109
+ manifest: tuple[HubFile, ...] = field(default_factory=tuple)
110
+
111
+ @property
112
+ def weights_sha256(self) -> str:
113
+ """What the content key was called when one file was all a submission could be."""
114
+ return self.content_key
115
+
116
+
117
+ def content_key(
118
+ files: Sequence[HubFile],
119
+ shape: Shape,
120
+ *,
121
+ fetch: Callable[[str], bytes] | None = None,
122
+ ) -> tuple[str, int]:
123
+ """The submission's content key and the bytes it covers, from the Hub's metadata.
124
+
125
+ `fetch` reads one small file whose sha256 the Hub does not know (a config, a statistics file);
126
+ it is never called for anything matching `lfs_only`, and never for a large file.
127
+ """
128
+ covered = sorted(
129
+ (f for f in files if shape.matches(f.name, shape.hashed)), key=lambda f: f.name.encode()
130
+ )
131
+ if not covered:
132
+ raise NotASubmission(f"holds none of {', '.join(shape.hashed)}")
133
+ digests = []
134
+ for one in covered:
135
+ if one.lfs_sha256:
136
+ digests.append(one.lfs_sha256)
137
+ continue
138
+ if shape.matches(one.name, shape.lfs_only):
139
+ raise NotASubmission(
140
+ f"{one.name} is not stored in Git LFS, so the Hub cannot say what is in it: "
141
+ "track it in .gitattributes and push it again"
142
+ )
143
+ if fetch is None or one.size > FETCH_MAX_BYTES:
144
+ raise NotASubmission(
145
+ f"{one.name} is {one.size} bytes and not in Git LFS; intake reads no more than "
146
+ f"{FETCH_MAX_BYTES} bytes of a submission"
147
+ )
148
+ digests.append(hashlib.sha256(fetch(one.name)).hexdigest())
149
+ size = sum(f.size for f in covered)
150
+ if shape.key == "file":
151
+ if len(covered) != 1:
152
+ raise NotASubmission(
153
+ f"holds {len(covered)} of {', '.join(shape.hashed)}; this competition takes one"
154
+ )
155
+ return digests[0], size
156
+ listing = "".join(f"{d} {f.name}\n" for d, f in zip(digests, covered, strict=True))
157
+ return hashlib.sha256(listing.encode()).hexdigest(), size
158
+
159
+
160
+ def manifest_of(info: Any) -> tuple[HubFile, ...]:
161
+ """What the Hub said the repository holds, as plain files."""
162
+ out = []
163
+ for sibling in info.siblings or []:
164
+ lfs = getattr(sibling, "lfs", None)
165
+ out.append(
166
+ HubFile(
167
+ name=str(sibling.rfilename),
168
+ size=int(getattr(sibling, "size", 0) or 0),
169
+ lfs_sha256=str(getattr(lfs, "sha256", "")) or None if lfs is not None else None,
170
+ )
171
+ )
172
+ return tuple(sorted(out, key=lambda f: f.name))
173
+
174
+
175
+ def inspect(
176
+ repo: str,
177
+ revision: str,
178
+ shape: Shape,
179
+ *,
180
+ api: Any = None,
181
+ token: str | None = None,
182
+ fetch: Callable[[str], bytes] | None = None,
183
+ ) -> HubSubmission:
184
+ """What `repo@revision` holds, from the Hub's metadata alone.
185
+
186
+ `fetch` reads one small file the Hub knows no sha256 for; the default downloads it. Nothing
187
+ large is ever fetched, and nothing at all for a competition whose key covers one LFS file.
188
+ """
189
+ from huggingface_hub import HfApi
190
+ from huggingface_hub.errors import (
191
+ GatedRepoError,
192
+ HfHubHTTPError,
193
+ RepositoryNotFoundError,
194
+ RevisionNotFoundError,
195
+ )
196
+
197
+ api = api or HfApi(token=token or None) # an empty token is no token
198
+ try:
199
+ info = api.model_info(repo, revision=revision, files_metadata=True)
200
+ except (RepositoryNotFoundError, RevisionNotFoundError, GatedRepoError) as exc:
201
+ raise NotVisible(f"{repo}@{revision}: {type(exc).__name__}") from None
202
+ except HfHubHTTPError as exc:
203
+ status = getattr(getattr(exc, "response", None), "status_code", None)
204
+ if status in (401, 403, 404):
205
+ raise NotVisible(f"{repo}@{revision}: {exc}") from None
206
+ raise HubUnreachable(f"{repo}@{revision}: {exc}") from None
207
+ except OSError as exc: # requests' connection errors and timeouts are OSErrors
208
+ raise HubUnreachable(f"{repo}@{revision}: {type(exc).__name__}: {exc}") from None
209
+ if getattr(info, "sha", None) != revision:
210
+ raise NotVisible(f"{repo}@{revision}: the Hub resolved it to {getattr(info, 'sha', None)}")
211
+
212
+ manifest = manifest_of(info)
213
+ names = tuple(f.name for f in manifest)
214
+ extra = [name for name in names if not shape.matches(name, shape.allowed)]
215
+ if extra:
216
+ raise NotASubmission(
217
+ f"holds files this competition's submission may not: {', '.join(extra[:5])}"
218
+ )
219
+ for pattern in shape.required:
220
+ if not any(shape.matches(name, [pattern]) for name in names):
221
+ raise NotASubmission(f"holds no {pattern}")
222
+
223
+ def download(name: str) -> bytes:
224
+ from huggingface_hub import hf_hub_download
225
+
226
+ path = hf_hub_download(repo, name, revision=revision, token=token or None)
227
+ with open(path, "rb") as handle:
228
+ return handle.read()
229
+
230
+ key, size = content_key(manifest, shape, fetch=fetch or download)
231
+ if shape.max_bytes and size > shape.max_bytes:
232
+ raise NotASubmission(
233
+ f"its weights are {size} bytes, over this competition's {shape.max_bytes}"
234
+ )
235
+ return HubSubmission(
236
+ repo=repo,
237
+ revision=revision,
238
+ content_key=key,
239
+ weights_bytes=size,
240
+ files=names,
241
+ manifest=manifest,
242
+ )
243
+
244
+
245
+ def upload_weights(
246
+ repo: str,
247
+ weights: str,
248
+ *,
249
+ token: str | None = None,
250
+ private: bool = False,
251
+ readme: str | None = None,
252
+ message: str = "Upload model.safetensors",
253
+ ) -> str:
254
+ """Upload `weights` as the repository's `model.safetensors` (and a README if given), creating
255
+ the repository if needed; the commit sha of the upload."""
256
+ from huggingface_hub import CommitOperationAdd, HfApi
257
+
258
+ api = HfApi(token=token or None)
259
+ api.create_repo(repo, repo_type="model", private=private, exist_ok=True)
260
+ operations = [CommitOperationAdd(path_in_repo=WEIGHTS_FILE, path_or_fileobj=weights)]
261
+ if readme is not None:
262
+ operations.append(
263
+ CommitOperationAdd(path_in_repo="README.md", path_or_fileobj=readme.encode())
264
+ )
265
+ info = api.create_commit(repo, operations=operations, commit_message=message, repo_type="model")
266
+ return str(info.oid)
robotensor/init.py ADDED
@@ -0,0 +1,74 @@
1
+ """`attune init`: a working directory, and a config that only needs your wallet filled in.
2
+
3
+ What is fiddly about running a validator is not the code, it is the paths around it. This writes a
4
+ config holding only what differs between hosts, and the directories its data is kept in.
5
+
6
+ What it will not do is guess a wallet or a netuid. Those are the two lines it leaves for you.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ CONFIG = "attune.toml"
14
+
15
+ VALIDATOR_TOML = """\
16
+ # Written by `attune init`. State, stores and runs are kept in var/ beside this file.
17
+ # `finney` is mainnet, `test` the test network, or a ws:// address of your own.
18
+ network = "{network}"
19
+ netuid = {netuid}
20
+ # The wallet this validator sets weights with. `btcli wallet list` shows what you have.
21
+ wallet = {{ name = "{wallet_name}", hotkey = "{wallet_hotkey}" }}
22
+
23
+ [vector]
24
+ # The two environments a duel needs, and the RoboTwin-Vector checkout the simulator runs from; see
25
+ # the README.
26
+ policy_python = "{policy_python}"
27
+ simulator_python = "{simulator_python}"
28
+ simulator_root = "{simulator_root}"
29
+ # A Hugging Face dataset the store is published to; empty to keep it local.
30
+ mirror = ""
31
+ """
32
+
33
+ MINER_NEXT = """\
34
+ A submission is weights and nothing else. Three commands, in this order:
35
+
36
+ attune miner check --dir {directory}/submission
37
+ attune miner upload --dir {directory}/submission --repo <you>/<name> --private
38
+ attune miner commit --repo <you>/<name> --revision <the sha upload printed> \\
39
+ --netuid <N> --network test --wallet.name <w> --wallet.hotkey <h>
40
+
41
+ Then make the repository public. Committing while it is private is what keeps your weights from
42
+ being copied and committed by somebody else first: the earliest commitment of a set of weights
43
+ keeps them.
44
+
45
+ This subnet charges nothing to submit.
46
+ """
47
+
48
+
49
+ def validator(directory: Path, **values: str) -> Path:
50
+ """Write a validator's config and the directories it names; the config's path."""
51
+ fields = {
52
+ "network": "test",
53
+ "netuid": "0",
54
+ "wallet_name": "validator",
55
+ "wallet_hotkey": "default",
56
+ "policy_python": "/path/to/policy-env/bin/python",
57
+ "simulator_python": "/path/to/simulator-env/bin/python",
58
+ "simulator_root": "/path/to/RoboTwin-Vector",
59
+ **values,
60
+ }
61
+ directory.mkdir(parents=True, exist_ok=True)
62
+ for made in ("var/state", "var/vector/store", "var/vector/runs", "var/vector/cache"):
63
+ (directory / made).mkdir(parents=True, exist_ok=True)
64
+ path = directory / CONFIG
65
+ if path.exists():
66
+ raise FileExistsError(f"{path} is already there; init will not write over a config")
67
+ path.write_text(VALIDATOR_TOML.format(**fields), encoding="utf-8")
68
+ return path
69
+
70
+
71
+ def miner(directory: Path) -> str:
72
+ """Make a miner's working directory; what to run next."""
73
+ (directory / "submission").mkdir(parents=True, exist_ok=True)
74
+ return MINER_NEXT.format(directory=directory)