openroboto-protocol 0.1.0__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.
- openroboto_protocol/__init__.py +27 -0
- openroboto_protocol/commitment.py +402 -0
- openroboto_protocol/constants.py +164 -0
- openroboto_protocol/model_format.py +376 -0
- openroboto_protocol/model_hash.py +104 -0
- openroboto_protocol/py.typed +0 -0
- openroboto_protocol/schemas.py +1578 -0
- openroboto_protocol/seed.py +116 -0
- openroboto_protocol/status.py +266 -0
- openroboto_protocol-0.1.0.dist-info/METADATA +338 -0
- openroboto_protocol-0.1.0.dist-info/RECORD +13 -0
- openroboto_protocol-0.1.0.dist-info/WHEEL +4 -0
- openroboto_protocol-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""OpenRoboto subnet protocol contract — the single source of truth.
|
|
2
|
+
|
|
3
|
+
Everything in this package is consumed by BOTH sides of the subnet: the private
|
|
4
|
+
backend that scores submissions, and the public miner / evaluation code that
|
|
5
|
+
produces them. Before this package existed the same algorithms lived as
|
|
6
|
+
hand-copied files across four repositories and had already drifted
|
|
7
|
+
(``protocol/types.py`` by 105 lines, ``payment.py`` by 313).
|
|
8
|
+
|
|
9
|
+
Three rules make this package worth existing:
|
|
10
|
+
|
|
11
|
+
1. **No I/O, no database, no secrets.** Pure functions and plain data only.
|
|
12
|
+
The moment this package fetches something over the network, the two sides
|
|
13
|
+
can no longer be proven to agree.
|
|
14
|
+
2. **The version number IS the contract version.** SemVer is enforced:
|
|
15
|
+
``patch`` fixes a bug without changing behaviour, ``minor`` adds an optional
|
|
16
|
+
field (old data missing the key must have a default), ``major`` is a
|
|
17
|
+
breaking change and needs an on-chain data migration plan plus review.
|
|
18
|
+
3. **Golden vectors are history.** Inputs and outputs that already happened on
|
|
19
|
+
chain are frozen as fixtures. Changing them is changing the past — a past
|
|
20
|
+
that decided who got paid.
|
|
21
|
+
|
|
22
|
+
Consumers must not vendor a copy of this code; their CI checks for it.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""链上 commitment payload 的编解码 —— 矿工写、后端读,双方必须逐字节一致。
|
|
2
|
+
|
|
3
|
+
矿工把一次提交的元数据 JSON 塞进 Bittensor 的 `Commitments.set_commitment`
|
|
4
|
+
(`Data::BigRaw`),后端扫链读回来。**这一个 JSON 就是提交本身** —— 它决定
|
|
5
|
+
哪个 HF 仓库被评测、算哪一轮、拿哪笔 burn 交易去核对。编码端和解码端对不上,
|
|
6
|
+
矿工就是烧了 TAO 没人看见。
|
|
7
|
+
|
|
8
|
+
## 链上 JSON 的形状
|
|
9
|
+
|
|
10
|
+
键名压到一个字母不是为了好看:`Data::BigRaw` 上限 512 字节,一个 hf_repo_id
|
|
11
|
+
加两个 64 位十六进制哈希已经吃掉一半。改键名 = major。
|
|
12
|
+
|
|
13
|
+
| 键 | 字段 | 契约含义 |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `s` | `hotkey_ss58` | 矿工 hotkey,SS58 全串 |
|
|
16
|
+
| `h` | `block_hash` | **矿工自报的**提交区块哈希,不可信,见下 |
|
|
17
|
+
| `c` | `hf_commit` | HuggingFace commit SHA,40 位十六进制 |
|
|
18
|
+
| `r` | `round_num` | 轮次号 |
|
|
19
|
+
| `i` | `hf_repo_id` | HuggingFace 仓库 id,如 `kyleab/pi05-scmGbsBoEmiQ` |
|
|
20
|
+
| `b` | `burn_tx_hash` | burn 交易哈希,**链上不带 `0x`** |
|
|
21
|
+
| `bb` | `burn_block` | burn 所在区块;0 时编码成 `null` |
|
|
22
|
+
|
|
23
|
+
## 两个必须记住的不对称
|
|
24
|
+
|
|
25
|
+
1. **`h` 是矿工自报的,不能直接喂给种子派生。** 扫链方拿到 `commit_block`
|
|
26
|
+
之后必须用 `get_block_hash(commit_block)` 覆盖它 —— 链给的那个带 `0x`,
|
|
27
|
+
矿工自报的那个不带。而 `derive_seed()` 是对字符串做 sha256,
|
|
28
|
+
多两个字符就是另一个种子、另一批评测任务。本模块**原样返回**矿工自报值
|
|
29
|
+
(不补 `0x`),覆盖是调用方的责任。
|
|
30
|
+
2. **`b` 反过来:解码时补 `0x`,编码时去 `0x`。** 生产两个消费方
|
|
31
|
+
(backend/chain_scanner.py、scripts/seed_data.py)都这么做,去重键依赖它。
|
|
32
|
+
|
|
33
|
+
## 变体名是长度,不是版本号
|
|
34
|
+
|
|
35
|
+
SDK 回来的字段键长这样:`BigRaw` / `Raw119` / `Raw82`。`RawN` 里的 N 是
|
|
36
|
+
**字节数**,128 字节以内用 `RawN`,超了才用 `BigRaw`。它不携带任何客户端版本信息。
|
|
37
|
+
|
|
38
|
+
2026-08 的结论「UID 71 四次提交三次是 Raw119,旧版 rt.py 走了 Raw 分支」是误判。
|
|
39
|
+
链上原始数据(Taostats extrinsic API,2026-08-17 复核):
|
|
40
|
+
|
|
41
|
+
8797897 Raw119 netuid=126 ← 别的子网
|
|
42
|
+
8808332 BigRaw netuid=80 ← 本子网,后端正常解出
|
|
43
|
+
8830097 Raw119 netuid=126 ← 别的子网
|
|
44
|
+
8830168 Raw119 netuid=126 ← 别的子网
|
|
45
|
+
|
|
46
|
+
同一个 hotkey 在多个子网上都发 commitment;那三条 Raw119 是 netuid 126 的
|
|
47
|
+
二进制 payload,本来就不该被 netuid 80 的后端看见。误判的根因是 Taostats
|
|
48
|
+
的 `netuid` 查询参数**被静默忽略**(实测:带 `netuid=80` 仍返回 123/126/68/47
|
|
49
|
+
等子网的记录),离线分析脚本按 signer_address 拉数据时把别的子网算进来了。
|
|
50
|
+
矿工没有白烧 TAO。
|
|
51
|
+
|
|
52
|
+
因此本模块**不为 `Raw119` 做任何特殊处理**:任何 `Raw*` / `BigRaw` 变体都按同一套
|
|
53
|
+
JSON 解,解不出来时用 `DecodeFailure` 说清楚是哪一类,把变体名一并带回给调用方
|
|
54
|
+
记日志。真正的版本漂移信号是 `UNKNOWN_SCHEMA`(是 JSON 对象但一个已知键都没有),
|
|
55
|
+
不是变体名。
|
|
56
|
+
|
|
57
|
+
## 不负责什么
|
|
58
|
+
|
|
59
|
+
不碰链、不查 netuid、不做 burn 校验、不覆盖 `h`、不判断轮次是否当期 ——
|
|
60
|
+
那些都要 I/O 或后端配置,留在调用方。
|
|
61
|
+
|
|
62
|
+
## 谁消费它
|
|
63
|
+
|
|
64
|
+
* 矿工 CLI(`rt.py` / `openroboto submit`)—— `encode()`,烧 TAO 之前调;
|
|
65
|
+
* 后端扫链器 —— `decode()`,`get_commitment_metadata()` 的返回直接喂进来;
|
|
66
|
+
* 历史回填脚本 —— `decode()`,喂索引器返回的 `__kind`/`value` 形状。
|
|
67
|
+
|
|
68
|
+
## 最小验证
|
|
69
|
+
|
|
70
|
+
`uv run pytest tests/test_commitment.py` —— 其中 GV-1 是链上区块 8808332
|
|
71
|
+
那 295 个真实字节,编解码互为逆运算这条一旦红,就是格式漂了。
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
from __future__ import annotations
|
|
75
|
+
|
|
76
|
+
import json
|
|
77
|
+
from collections.abc import Iterator, Mapping
|
|
78
|
+
from dataclasses import dataclass
|
|
79
|
+
from enum import StrEnum
|
|
80
|
+
from typing import Any, Final
|
|
81
|
+
|
|
82
|
+
# `Data::BigRaw` 的硬上限。超过这个长度的 extrinsic 会被链拒绝 ——
|
|
83
|
+
# 而 burn 是在提交之前就花掉的,所以必须在花钱之前发现。
|
|
84
|
+
MAX_COMMITMENT_BYTES: Final = 512
|
|
85
|
+
|
|
86
|
+
# 链上 JSON 的全部已知键。解码时「一个都不认识」= 对方不是本协议的客户端。
|
|
87
|
+
PAYLOAD_KEYS: Final = ("s", "h", "c", "r", "i", "b", "bb")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class DecodeFailure(StrEnum):
|
|
91
|
+
"""解码失败的分类 —— 调用方靠它区分「噪音」和「真出事了」。
|
|
92
|
+
|
|
93
|
+
生产实测(2026-08-14,3000 行扫链日志):772 条解码失败里 768 条是
|
|
94
|
+
`NO_COMMITMENT`(该 hotkey 链上根本没提交过),4 条是 `NOT_UTF8`
|
|
95
|
+
(别的子网的二进制 payload)。旧扫链器把这两类和真故障一起打成
|
|
96
|
+
`decode FAILED` WARNING,于是 768 条噪音把真信号埋了。
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
NO_COMMITMENT = "no_commitment"
|
|
100
|
+
"""链上没有这条 commitment:`None`、空串、或 `info.fields` 里没有可用字段。
|
|
101
|
+
这是**正常状态**,不是错误 —— 绝大多数 hotkey 从没提交过。"""
|
|
102
|
+
|
|
103
|
+
NOT_HEX = "not_hex"
|
|
104
|
+
"""字段值声明成 `0x...` 但不是合法十六进制。"""
|
|
105
|
+
|
|
106
|
+
NOT_UTF8 = "not_utf8"
|
|
107
|
+
"""字节流不是 UTF-8。基本可以断定是别的子网的二进制 payload。"""
|
|
108
|
+
|
|
109
|
+
NOT_JSON = "not_json"
|
|
110
|
+
"""是 UTF-8 文本但不是合法 JSON。"""
|
|
111
|
+
|
|
112
|
+
NOT_OBJECT = "not_object"
|
|
113
|
+
"""是合法 JSON 但顶层不是对象(比如是数组或数字)。"""
|
|
114
|
+
|
|
115
|
+
UNKNOWN_SCHEMA = "unknown_schema"
|
|
116
|
+
"""是 JSON 对象,但 `PAYLOAD_KEYS` 一个都不出现。
|
|
117
|
+
**这才是客户端版本漂移的真信号** —— 对方在用一套我们不认识的键名。"""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class CommitmentDecodeError(ValueError):
|
|
121
|
+
"""解码失败。`reason` 用于分类统计,`detail` 用于人读。"""
|
|
122
|
+
|
|
123
|
+
def __init__(self, reason: DecodeFailure, detail: str = "") -> None:
|
|
124
|
+
super().__init__(f"{reason.value}: {detail}" if detail else reason.value)
|
|
125
|
+
self.reason = reason
|
|
126
|
+
self.detail = detail
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class CommitmentTooLargeError(ValueError):
|
|
130
|
+
"""编码结果超过 `MAX_COMMITMENT_BYTES`,这条 commitment 上不了链。
|
|
131
|
+
|
|
132
|
+
必须在矿工烧 TAO **之前**抛出:burn 先发生、提交后发生,
|
|
133
|
+
提交失败不会把钱退回来。实际唯一可能撑爆的字段是 `hf_repo_id`。
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
def __init__(self, size: int) -> None:
|
|
137
|
+
super().__init__(
|
|
138
|
+
f"commitment payload {size} bytes > {MAX_COMMITMENT_BYTES} limit; "
|
|
139
|
+
f"shorten hf_repo_id"
|
|
140
|
+
)
|
|
141
|
+
self.size = size
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True)
|
|
145
|
+
class CommitmentPayload:
|
|
146
|
+
"""一次提交的全部链上字段 —— 必须同源,所以绑成一个不可变整体。
|
|
147
|
+
|
|
148
|
+
拆开传参数会让「这个 block_hash 配那个 burn_tx_hash」变成可能,
|
|
149
|
+
而这两者一旦错配,seed 和 burn 校验会各自指向不同的提交。
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
hotkey_ss58: str
|
|
153
|
+
"""矿工 hotkey(SS58)。链上 `s`。"""
|
|
154
|
+
|
|
155
|
+
block_hash: str
|
|
156
|
+
"""**矿工自报的**提交区块哈希,链上 `h`,不带 `0x`。
|
|
157
|
+
|
|
158
|
+
⚠️ 不可信。扫链方必须用 `get_block_hash(commit_block)` 覆盖之后
|
|
159
|
+
再拿去派生种子,否则矿工可以自选种子。"""
|
|
160
|
+
|
|
161
|
+
hf_commit: str
|
|
162
|
+
"""HuggingFace commit SHA,链上 `c`,40 位十六进制。
|
|
163
|
+
生产上出现过空值(矿工没填 commit URL),本模块不拦,由后端 HF 校验拒。"""
|
|
164
|
+
|
|
165
|
+
round_num: int
|
|
166
|
+
"""轮次号,链上 `r`。缺失按 0 处理 —— 0 永远不等于当期轮次,会被后端拒掉。"""
|
|
167
|
+
|
|
168
|
+
hf_repo_id: str
|
|
169
|
+
"""HuggingFace 仓库 id,链上 `i`,如 `kyleab/pi05-scmGbsBoEmiQ`。"""
|
|
170
|
+
|
|
171
|
+
burn_tx_hash: str
|
|
172
|
+
"""burn 交易哈希,链上 `b`。
|
|
173
|
+
|
|
174
|
+
链上存的**不带** `0x`;`decode()` 解出来时补上 `0x`,`encode()` 写回时去掉。
|
|
175
|
+
去重键(hotkey + round + burn_tx_hash)依赖这个规范化形式。"""
|
|
176
|
+
|
|
177
|
+
burn_block: int
|
|
178
|
+
"""burn 所在区块,链上 `bb`。0 表示矿工没报,编码时写成 `null`。"""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True)
|
|
182
|
+
class DecodedCommitment:
|
|
183
|
+
"""`decode()` 的结果:矿工自报的 payload + 链信封给的、可信的那部分。
|
|
184
|
+
|
|
185
|
+
两者分开放是因为信任级别不同:`payload` 全是矿工写的,
|
|
186
|
+
`commit_block` 是链节点返回的。
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
payload: CommitmentPayload
|
|
190
|
+
"""矿工写在链上的那个 JSON。全部字段都是自报值。"""
|
|
191
|
+
|
|
192
|
+
commit_block: int
|
|
193
|
+
"""commitment 所在区块号,来自链返回的元数据信封(可信)。
|
|
194
|
+
调用方拿它去 `get_block_hash()` 换真区块哈希。输入里没有信封时为 0。"""
|
|
195
|
+
|
|
196
|
+
data_variant: str
|
|
197
|
+
"""SDK 报出的 `Data` 变体名:`BigRaw` / `Raw119` / …。
|
|
198
|
+
|
|
199
|
+
只是**字节长度标签**,不是版本号(见模块 docstring)。带回来是为了让
|
|
200
|
+
调用方能在日志里看见形状变化,不要拿它做分支判断。
|
|
201
|
+
直接传字节或 dict 时为空串。"""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def encode(payload: CommitmentPayload) -> bytes:
|
|
205
|
+
"""把 payload 编成上链的字节。键序和分隔符是契约的一部分,不能改。
|
|
206
|
+
|
|
207
|
+
`json.dumps(separators=(",", ":"))` 的紧凑形式 + 固定键序 `s,h,c,r,i,b,bb`
|
|
208
|
+
是链上历史数据的既成事实(见 tests/test_golden_vectors.py)。
|
|
209
|
+
换个键序不会解码失败,但字节不再一致 —— 任何按字节比对的核对都会炸。
|
|
210
|
+
|
|
211
|
+
超过 512 字节抛 `CommitmentTooLargeError`:这样的 payload 上不了链,
|
|
212
|
+
而矿工此时已经烧过 TAO 了。
|
|
213
|
+
"""
|
|
214
|
+
data = {
|
|
215
|
+
"s": payload.hotkey_ss58,
|
|
216
|
+
"h": _strip_0x(payload.block_hash),
|
|
217
|
+
"c": payload.hf_commit,
|
|
218
|
+
"r": payload.round_num,
|
|
219
|
+
"i": payload.hf_repo_id,
|
|
220
|
+
"b": _strip_0x(payload.burn_tx_hash),
|
|
221
|
+
# 0 写成 null 是历史形状,不是笔误。
|
|
222
|
+
"bb": payload.burn_block or None,
|
|
223
|
+
}
|
|
224
|
+
blob = json.dumps(data, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
225
|
+
if len(blob) > MAX_COMMITMENT_BYTES:
|
|
226
|
+
raise CommitmentTooLargeError(len(blob))
|
|
227
|
+
return blob
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def decode(raw: object) -> DecodedCommitment:
|
|
231
|
+
"""把扫链拿到的东西解成 `DecodedCommitment`,失败抛 `CommitmentDecodeError`。
|
|
232
|
+
|
|
233
|
+
接受 SDK 实际会返回的全部形状(生产上一个都不能少):
|
|
234
|
+
|
|
235
|
+
* `bytes` / `str` —— 已经是 payload 本身;
|
|
236
|
+
* `{"deposit":…, "block": N, "info": {"fields": [{"BigRaw": …}]}}`
|
|
237
|
+
—— `get_commitment_metadata()` 的返回;字段值可能是 JSON 文本、
|
|
238
|
+
`0x` 十六进制串、或整数元组,取决于 SDK 版本;
|
|
239
|
+
* `{"__kind": "BigRaw", "value": "0x…"}` —— 索引器(Taostats/subsquid)的形状;
|
|
240
|
+
* 直接就是 payload 的 dict —— 老代码的兜底路径。
|
|
241
|
+
|
|
242
|
+
只取**第一个**可用的 `Raw*` / `BigRaw` 字段。链上一次 commitment 就一个字段,
|
|
243
|
+
出现多个说明形状已经不是我们认识的了,与其猜不如让上面那条报出来。
|
|
244
|
+
"""
|
|
245
|
+
if raw is None:
|
|
246
|
+
raise CommitmentDecodeError(DecodeFailure.NO_COMMITMENT, "raw is None")
|
|
247
|
+
|
|
248
|
+
if isinstance(raw, bytes | bytearray):
|
|
249
|
+
if not raw:
|
|
250
|
+
raise CommitmentDecodeError(DecodeFailure.NO_COMMITMENT, "empty bytes")
|
|
251
|
+
return DecodedCommitment(_payload_from_bytes(bytes(raw)), 0, "")
|
|
252
|
+
|
|
253
|
+
if isinstance(raw, str):
|
|
254
|
+
# 生产实测:没有 commitment 的 hotkey,SDK 返回的是空串而不是 None。
|
|
255
|
+
if not raw:
|
|
256
|
+
raise CommitmentDecodeError(DecodeFailure.NO_COMMITMENT, "empty string")
|
|
257
|
+
return DecodedCommitment(_payload_from_bytes(raw.encode("utf-8")), 0, "")
|
|
258
|
+
|
|
259
|
+
if isinstance(raw, Mapping):
|
|
260
|
+
commit_block = _as_int(raw.get("block"))
|
|
261
|
+
for variant, value in _iter_data_fields(raw):
|
|
262
|
+
blob = _field_bytes(value)
|
|
263
|
+
if blob:
|
|
264
|
+
return DecodedCommitment(
|
|
265
|
+
_payload_from_bytes(blob), commit_block, variant
|
|
266
|
+
)
|
|
267
|
+
# 兜底:信封里没有字段,但这个 dict 自己就长得像 payload。
|
|
268
|
+
if _has_known_key(raw):
|
|
269
|
+
return DecodedCommitment(_payload_from_mapping(raw), commit_block, "")
|
|
270
|
+
raise CommitmentDecodeError(
|
|
271
|
+
DecodeFailure.NO_COMMITMENT, "no Raw*/BigRaw field in info.fields"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
raise CommitmentDecodeError(
|
|
275
|
+
DecodeFailure.NO_COMMITMENT, f"unsupported type {type(raw).__name__}"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ─── 内部实现 ────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _strip_0x(value: str) -> str:
|
|
283
|
+
return value[2:] if value.startswith("0x") else value
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _as_str(value: object) -> str:
|
|
287
|
+
"""非字符串一律当空 —— 矿工写了 `"i": 123` 这种,后端 HF 校验会拒。"""
|
|
288
|
+
return value if isinstance(value, str) else ""
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _as_int(value: object, default: int = 0) -> int:
|
|
292
|
+
"""`None` / 非数字 → default。链上 `bb` 缺省就是 `null`。"""
|
|
293
|
+
if isinstance(value, bool):
|
|
294
|
+
return default
|
|
295
|
+
if isinstance(value, int):
|
|
296
|
+
return value
|
|
297
|
+
if isinstance(value, str):
|
|
298
|
+
try:
|
|
299
|
+
return int(value)
|
|
300
|
+
except ValueError:
|
|
301
|
+
return default
|
|
302
|
+
return default
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _has_known_key(data: Mapping[Any, Any]) -> bool:
|
|
306
|
+
return any(key in data for key in PAYLOAD_KEYS)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _iter_data_fields(raw: Mapping[Any, Any]) -> Iterator[tuple[str, object]]:
|
|
310
|
+
"""从链信封里掏出 (变体名, 字段值)。
|
|
311
|
+
|
|
312
|
+
`info.fields` 在不同 SDK 版本里是 `[{...}]` 或 `[[{...}]]`,两层都要认。
|
|
313
|
+
"""
|
|
314
|
+
info = raw.get("info")
|
|
315
|
+
if not isinstance(info, Mapping):
|
|
316
|
+
return
|
|
317
|
+
fields = info.get("fields")
|
|
318
|
+
if not isinstance(fields, list | tuple):
|
|
319
|
+
return
|
|
320
|
+
for group in fields:
|
|
321
|
+
entries = (group,) if isinstance(group, Mapping) else group
|
|
322
|
+
if not isinstance(entries, list | tuple):
|
|
323
|
+
continue
|
|
324
|
+
for entry in entries:
|
|
325
|
+
if not isinstance(entry, Mapping):
|
|
326
|
+
continue
|
|
327
|
+
# 索引器形状:{"__kind": "BigRaw", "value": "0x…"}
|
|
328
|
+
kind = entry.get("__kind")
|
|
329
|
+
if isinstance(kind, str) and _is_data_variant(kind):
|
|
330
|
+
yield kind, entry.get("value")
|
|
331
|
+
continue
|
|
332
|
+
# SDK 形状:{"BigRaw": …}
|
|
333
|
+
for key, value in entry.items():
|
|
334
|
+
if isinstance(key, str) and _is_data_variant(key):
|
|
335
|
+
yield key, value
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _is_data_variant(key: str) -> bool:
|
|
339
|
+
"""`RawN`(N = 字节数,≤128)或 `BigRaw`(>128)。N 不是版本号。"""
|
|
340
|
+
return key == "BigRaw" or key.startswith("Raw")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _field_bytes(value: object) -> bytes | None:
|
|
344
|
+
"""把字段值还原成字节。认不出来返回 `None`(继续看下一个字段)。"""
|
|
345
|
+
if isinstance(value, bytes | bytearray):
|
|
346
|
+
return bytes(value)
|
|
347
|
+
if isinstance(value, str):
|
|
348
|
+
if value.startswith("0x"):
|
|
349
|
+
try:
|
|
350
|
+
return bytes.fromhex(value[2:])
|
|
351
|
+
except ValueError as exc:
|
|
352
|
+
raise CommitmentDecodeError(DecodeFailure.NOT_HEX, str(exc)) from exc
|
|
353
|
+
return value.encode("utf-8")
|
|
354
|
+
if isinstance(value, list | tuple):
|
|
355
|
+
flat: list[int] = []
|
|
356
|
+
for item in value:
|
|
357
|
+
if isinstance(item, list | tuple):
|
|
358
|
+
flat.extend(i for i in item if isinstance(i, int))
|
|
359
|
+
elif isinstance(item, int):
|
|
360
|
+
flat.append(item)
|
|
361
|
+
try:
|
|
362
|
+
return bytes(flat)
|
|
363
|
+
except ValueError:
|
|
364
|
+
return None
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _payload_from_bytes(blob: bytes) -> CommitmentPayload:
|
|
369
|
+
try:
|
|
370
|
+
text = blob.decode("utf-8")
|
|
371
|
+
except UnicodeDecodeError as exc:
|
|
372
|
+
raise CommitmentDecodeError(DecodeFailure.NOT_UTF8, str(exc)) from exc
|
|
373
|
+
try:
|
|
374
|
+
data = json.loads(text)
|
|
375
|
+
except ValueError as exc:
|
|
376
|
+
raise CommitmentDecodeError(DecodeFailure.NOT_JSON, str(exc)) from exc
|
|
377
|
+
if not isinstance(data, dict):
|
|
378
|
+
raise CommitmentDecodeError(
|
|
379
|
+
DecodeFailure.NOT_OBJECT, f"top level is {type(data).__name__}"
|
|
380
|
+
)
|
|
381
|
+
if not _has_known_key(data):
|
|
382
|
+
raise CommitmentDecodeError(
|
|
383
|
+
DecodeFailure.UNKNOWN_SCHEMA, f"keys={sorted(map(str, data))[:8]}"
|
|
384
|
+
)
|
|
385
|
+
return _payload_from_mapping(data)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _payload_from_mapping(data: Mapping[Any, Any]) -> CommitmentPayload:
|
|
389
|
+
"""缺键一律走默认值 —— minor 版本加了新键的老数据必须还能解出来。"""
|
|
390
|
+
burn_tx_hash = _as_str(data.get("b"))
|
|
391
|
+
if burn_tx_hash and not burn_tx_hash.startswith("0x"):
|
|
392
|
+
burn_tx_hash = f"0x{burn_tx_hash}"
|
|
393
|
+
return CommitmentPayload(
|
|
394
|
+
hotkey_ss58=_as_str(data.get("s")),
|
|
395
|
+
# 不补 0x:矿工自报值原样返回,见模块 docstring 的不对称说明。
|
|
396
|
+
block_hash=_as_str(data.get("h")),
|
|
397
|
+
hf_commit=_as_str(data.get("c")),
|
|
398
|
+
round_num=_as_int(data.get("r")),
|
|
399
|
+
hf_repo_id=_as_str(data.get("i")),
|
|
400
|
+
burn_tx_hash=burn_tx_hash,
|
|
401
|
+
burn_block=_as_int(data.get("bb")),
|
|
402
|
+
)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""协议常量 —— 每一个都写清单位和量纲。
|
|
2
|
+
|
|
3
|
+
写单位不是洁癖。`CHAMPION_MARGIN` 的旧注释写成 `min avg_score margin to dethrone
|
|
4
|
+
king (2%)`,值却是 `0.01`(avg_score 的绝对差值,不是百分比)。那个 "(2%)" 已经
|
|
5
|
+
误导过一次。排放权重更狠:全仓有两套数字(0.07/0.02/0.01 与 0.70/0.20/0.10),
|
|
6
|
+
没有任何一处说明它们是不同口径,看到哪份就以为是哪份。
|
|
7
|
+
|
|
8
|
+
**这个模块不负责**:运行时可调的后端参数。按 ADR 01,后端行为参数的唯一来源是
|
|
9
|
+
`backend.yaml`,`control.json` 只承载 payment / dataset / training / process。
|
|
10
|
+
这里放的是"两边必须有一致理解"的协议常量;`backend.yaml` 能覆盖的项会逐条注明。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Final
|
|
17
|
+
|
|
18
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
# 排放权重
|
|
20
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class EmissionWeights:
|
|
25
|
+
"""Top-K 排放权重。**两种口径绑在一起,只能有一个输入。**
|
|
26
|
+
|
|
27
|
+
同一件事全仓有两套数字,且没有任何地方说明它们的区别:
|
|
28
|
+
|
|
29
|
+
=============================== ================================ ==========
|
|
30
|
+
数字 口径 是否生效
|
|
31
|
+
=============================== ================================ ==========
|
|
32
|
+
``[0.07, 0.02, 0.01]`` 占**全网总排放**的绝对份额 **生效**
|
|
33
|
+
``[0.70, 0.20, 0.10]`` Top-3 **之间**的相对分配 从未上线
|
|
34
|
+
=============================== ================================ ==========
|
|
35
|
+
|
|
36
|
+
数学上一致:``0.70 × (1 − 0.90 burn) = 0.07``。所以两者不是矛盾,是同一件事的
|
|
37
|
+
两种表达 —— 但混着用一定出事,因为 rank 1 拿 "0.7" 还是 "0.07" 差十倍。
|
|
38
|
+
|
|
39
|
+
相对口径出现在 `owner/tools/round_controller.py:98` 生成的 control.json 里
|
|
40
|
+
(键名 ``emission_weights``)。2026-08-17 实测 https://api.openroboto.ai/control.json
|
|
41
|
+
**没有这个键** —— 它从未上线,链上排放一直走绝对口径。
|
|
42
|
+
|
|
43
|
+
这里只存绝对份额,相对份额和 burn 份额都是**算出来的**,因此永远对得上。
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
#: rank 1..K 各自占全网总排放的份额。降序,和 < 1(差额被 burn 掉)。
|
|
47
|
+
#: 量纲:无量纲比例,1.0 = 全网总排放。
|
|
48
|
+
absolute: tuple[float, ...]
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def top_k(self) -> int:
|
|
52
|
+
"""进榜名额。榜单长度就是权重条数 —— 不允许两个数各写一处。"""
|
|
53
|
+
return len(self.absolute)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def relative(self) -> tuple[float, ...]:
|
|
57
|
+
"""Top-K **之间**的相对分配(和为 1.0)。control.json 那套口径。"""
|
|
58
|
+
total = sum(self.absolute)
|
|
59
|
+
return tuple(w / total for w in self.absolute)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def burn_share(self) -> float:
|
|
63
|
+
"""没进榜的那部分 —— 全部烧掉。量纲同 `absolute`。
|
|
64
|
+
|
|
65
|
+
⚠️ 这是**协议口径的推论**(1 − Top-K 之和)。生产实际写链时用的是
|
|
66
|
+
`backend.yaml` 的 `scanner.burn_ratio`(线上值 0.9)。两者当前一致;
|
|
67
|
+
真要改哪个都必须同时改另一个,否则权重和不为 1。
|
|
68
|
+
"""
|
|
69
|
+
return 1.0 - sum(self.absolute)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
#: 生效口径的排放权重。**改这里就是改矿工到手的钱。**
|
|
73
|
+
TOP_K_EMISSION: Final[EmissionWeights] = EmissionWeights(
|
|
74
|
+
absolute=(0.07, 0.02, 0.01),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
#: 兼容旧名(`protocol/types.py:82` 一直叫这个)。元组而非列表:常量不该可变。
|
|
78
|
+
TOP_K_EMISSION_WEIGHTS: Final[tuple[float, ...]] = TOP_K_EMISSION.absolute
|
|
79
|
+
|
|
80
|
+
#: 进榜名额(King of the Hill 的榜单长度)。与权重条数同源。
|
|
81
|
+
TOP_K: Final[int] = TOP_K_EMISSION.top_k
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
85
|
+
# 夺擂门槛
|
|
86
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
#: 挑战者要夺擂,avg_score 必须 **严格大于** 擂主 + 这个值。
|
|
89
|
+
#:
|
|
90
|
+
#: 单位:avg_score 的**绝对差值**(avg_score 本身是 0..1 的 6 个 suite 均值),
|
|
91
|
+
#: 所以 0.01 = 0.01 分,**不是 1%,更不是旧注释写的 "(2%)"**。
|
|
92
|
+
#:
|
|
93
|
+
#: 比较符是严格大于,差值正好等于 margin 时挑战**失败**
|
|
94
|
+
#: (`backend/db/rankings.py:224` 的 `chall_avg > target_avg + margin`)。
|
|
95
|
+
#: 平局判失败是反抄袭的基石:复制擂主的权重只能打平,打平输在 margin 上。
|
|
96
|
+
#: 任何一处写成 `>=` 都等于单方面拆掉这道门槛。
|
|
97
|
+
#:
|
|
98
|
+
#: ⚠️ 运行时可被 `backend.yaml` 的 `ranking.champion_margin` 覆盖(线上值同为 0.01)。
|
|
99
|
+
#: 这里是协议默认值;矿工据此估算能不能夺擂,两边不一致就是矿工白烧 TAO。
|
|
100
|
+
CHAMPION_MARGIN: Final[float] = 0.01
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
104
|
+
# LIBERO 评测环境
|
|
105
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
#: 一次评测要跑的 LIBERO task suite,顺序即派发给 worker 的 `env_list` 顺序。
|
|
108
|
+
#: 2026-08-17 线上 117 条提交的 `env_list` 与 82 条出分记录的 `env_scores`
|
|
109
|
+
#: 都恰好是这 6 个,一个不多一个不少。
|
|
110
|
+
LIBERO_TASK_SUITES: Final[tuple[str, ...]] = (
|
|
111
|
+
"libero_spatial",
|
|
112
|
+
"libero_object",
|
|
113
|
+
"libero_goal",
|
|
114
|
+
"libero_10",
|
|
115
|
+
"libero_object_swap",
|
|
116
|
+
"libero_spatial_swap",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
#: 出分时必须齐全的环境。缺一个就整条拒收(`MISSING_ENVS`),任务保持原状态好让
|
|
120
|
+
#: worker 重试 —— 3 个 suite 的均值和 6 个的均值不可比,混进同一张榜就是送分。
|
|
121
|
+
#: 与 `LIBERO_TASK_SUITES` 同源:不存在"允许跑 6 个但只要求 4 个"这种半截配置。
|
|
122
|
+
REQUIRED_ENVS: Final[frozenset[str]] = frozenset(LIBERO_TASK_SUITES)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
126
|
+
# drand 随机信标
|
|
127
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True, slots=True)
|
|
131
|
+
class DrandBeacon:
|
|
132
|
+
"""drand 链的三个参数,绑成一条记录 —— 它们只能同时改。
|
|
133
|
+
|
|
134
|
+
`genesis_time` 和 `period_seconds` 决定"某个时间点对应第几轮",
|
|
135
|
+
`chain_hash` 决定"去哪条链取这一轮"。串了任意一个,算出来的 round 指向另一条链
|
|
136
|
+
的随机数,种子随之改变,而 **种子一变,历史评测就不可复现**(spec §5)。
|
|
137
|
+
分开写三个模块常量,就给了串用的机会。
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
#: drand 链标识(也是 API 路径的一段)。
|
|
141
|
+
chain_hash: str
|
|
142
|
+
#: 该链第 1 轮的 Unix 时间戳(秒)。
|
|
143
|
+
genesis_time: int
|
|
144
|
+
#: 出块周期(秒)。
|
|
145
|
+
period_seconds: int
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
#: 生产使用的 drand 链:League of Entropy 默认链(beaconID `default`,
|
|
149
|
+
#: schemeID `pedersen-bls-chained`)。
|
|
150
|
+
#:
|
|
151
|
+
#: 2026-08-17 对 `https://api.drand.sh/<chain_hash>/info` 实测核对:
|
|
152
|
+
#: period=30、genesis_time=1595431050、hash 一致。
|
|
153
|
+
#:
|
|
154
|
+
#: 轮次换算公式(`max(1, (ts - genesis) // period + 1)`)和取随机数的网络请求都
|
|
155
|
+
#: **不在这个模块里** —— 前者属于 `seed.py`,后者是 I/O,属于后端。
|
|
156
|
+
#:
|
|
157
|
+
#: ⚠️ `seed.py` 目前另有一份同值的 `DRAND_CHAIN_HASH`(拼 URL 用)。同一个常量两份
|
|
158
|
+
#: 副本正是这个包要消灭的东西;在收敛成一处之前,`tests/test_constants.py` 里有一条
|
|
159
|
+
#: 断言盯着两者相等,漂了立刻变红。
|
|
160
|
+
DRAND_DEFAULT_CHAIN: Final[DrandBeacon] = DrandBeacon(
|
|
161
|
+
chain_hash="8990e7a9aaed2ffed73dbd7092123d6f289930540d7651336225dc172e51b2ce",
|
|
162
|
+
genesis_time=1595431050,
|
|
163
|
+
period_seconds=30,
|
|
164
|
+
)
|