openroboto-protocol 0.3.0__tar.gz → 0.4.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openroboto-protocol
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Shared protocol contract for the OpenRoboto subnet on Bittensor (netuid 80): seed derivation, commitment codec, model hashing, status vocabulary and API schemas.
5
5
  Keywords: bittensor,openroboto,subnet,protocol
6
6
  Author: OpenRoboto
@@ -31,9 +31,9 @@ Pin the exact version. A floating range means two sides of the subnet can resolv
31
31
  to different code, which is the failure this package was created to prevent.
32
32
 
33
33
  ```bash
34
- uv add "openroboto-protocol==0.3.0"
34
+ uv add "openroboto-protocol==0.4.0"
35
35
  # or
36
- pip install "openroboto-protocol==0.3.0"
36
+ pip install "openroboto-protocol==0.4.0"
37
37
  ```
38
38
 
39
39
  ```python
@@ -51,7 +51,7 @@ seed and decode a commitment; that must not cost a `pydantic-core` wheel build o
51
51
  GPU box. Only `schemas.py` needs pydantic, and only the backend needs `schemas.py`:
52
52
 
53
53
  ```bash
54
- uv add "openroboto-protocol[schemas]==0.3.0" # backend only
54
+ uv add "openroboto-protocol[schemas]==0.4.0" # backend only
55
55
  ```
56
56
 
57
57
  ## Why this package exists
@@ -121,7 +121,7 @@ The version number *is* the contract version.
121
121
  | `minor` | New optional field. Old data missing the key **must** have a default |
122
122
  | `major` | Breaking change. Requires an on-chain data migration plan and review |
123
123
 
124
- Consumers pin an exact version (`openroboto-protocol==0.3.0`). Floating versions are
124
+ Consumers pin an exact version (`openroboto-protocol==0.4.0`). Floating versions are
125
125
  rejected in CI, as is any vendored copy of this code.
126
126
 
127
127
  ## What consumers must add to their own CI
@@ -9,9 +9,9 @@ Pin the exact version. A floating range means two sides of the subnet can resolv
9
9
  to different code, which is the failure this package was created to prevent.
10
10
 
11
11
  ```bash
12
- uv add "openroboto-protocol==0.3.0"
12
+ uv add "openroboto-protocol==0.4.0"
13
13
  # or
14
- pip install "openroboto-protocol==0.3.0"
14
+ pip install "openroboto-protocol==0.4.0"
15
15
  ```
16
16
 
17
17
  ```python
@@ -29,7 +29,7 @@ seed and decode a commitment; that must not cost a `pydantic-core` wheel build o
29
29
  GPU box. Only `schemas.py` needs pydantic, and only the backend needs `schemas.py`:
30
30
 
31
31
  ```bash
32
- uv add "openroboto-protocol[schemas]==0.3.0" # backend only
32
+ uv add "openroboto-protocol[schemas]==0.4.0" # backend only
33
33
  ```
34
34
 
35
35
  ## Why this package exists
@@ -99,7 +99,7 @@ The version number *is* the contract version.
99
99
  | `minor` | New optional field. Old data missing the key **must** have a default |
100
100
  | `major` | Breaking change. Requires an on-chain data migration plan and review |
101
101
 
102
- Consumers pin an exact version (`openroboto-protocol==0.3.0`). Floating versions are
102
+ Consumers pin an exact version (`openroboto-protocol==0.4.0`). Floating versions are
103
103
  rejected in CI, as is any vendored copy of this code.
104
104
 
105
105
  ## What consumers must add to their own CI
@@ -10,7 +10,7 @@ name = "openroboto-protocol"
10
10
  # (0.1.0 → 0.2.0)这样收紧 schemas 的哨兵默认值是允许的。
11
11
  #
12
12
  # ⚠️ 0.1.0 已经发到 PyPI(2026-08-19,OIDC)。PyPI 版本号不可重用,只能往前发。
13
- version = "0.3.0"
13
+ version = "0.4.0"
14
14
  description = "Shared protocol contract for the OpenRoboto subnet on Bittensor (netuid 80): seed derivation, commitment codec, model hashing, status vocabulary and API schemas."
15
15
  readme = "README.md"
16
16
  license = "MIT"
@@ -0,0 +1,116 @@
1
+ """Weight normalisation — the last step before emissions land on chain.
2
+
3
+ **What it promises**: given the backend's `{hotkey: share}` and the metagraph's
4
+ hotkey list, everyone computes the same `(uid, u16)` pair. The backend's
5
+ chain-writer and every external validator running the CLI feed the chain from
6
+ this one function, so "the backend computed one thing and the validator wrote
7
+ another" cannot happen by divergence.
8
+
9
+ It used to happen by copying. Two implementations existed --
10
+ `openroboto-backend/app/services/chain_writer.py` and
11
+ `openroboto-cli/src/openroboto/chain/weights.py` -- byte-for-byte identical in
12
+ arithmetic and different in every name, return type and log string. Nothing
13
+ compared them. Two copies of an expression whose floating-point shape *is* the
14
+ behaviour is the definition of a red line worth centralising: a "cleanup" in
15
+ one of them, months apart, silently changes who gets paid.
16
+
17
+ **What it is not responsible for**:
18
+
19
+ - Deciding the shares. That is King-of-the-Hill, and it stays in the backend
20
+ (`app/domain/ranking.py`) -- it needs scores, history and a database.
21
+ - Sending the extrinsic. Both callers keep their own `set_weights` because the
22
+ SDK result shapes and the retry policy differ between them.
23
+ - Knowing which hotkeys are registered. The caller passes the metagraph list.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from dataclasses import dataclass
29
+ from typing import Final
30
+
31
+ __all__ = ["U16_MAX", "NormalizedWeights", "normalize_weights"]
32
+
33
+ #: Maximum weight the chain accepts, as an unsigned 16-bit integer.
34
+ U16_MAX: Final = 65535
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class NormalizedWeights:
39
+ """What `set_weights` needs: two parallel lists plus the evidence.
40
+
41
+ `uids` and `weights` are positional pairs, not a mapping, because that is
42
+ the shape the chain call takes.
43
+ """
44
+
45
+ uids: list[int]
46
+ #: u16 in [0, 65535]. **Not shares** -- shares are what came in.
47
+ weights: list[int]
48
+ #: Per-entry lines, written straight to the log. When weights turn out
49
+ #: wrong, this is the only evidence of what went in and what came out.
50
+ detail: list[str]
51
+
52
+
53
+ def normalize_weights(
54
+ weights_raw: dict[str, float], hotkeys: list[str]
55
+ ) -> NormalizedWeights:
56
+ """Turn `{hotkey: share}` into the `(uid, u16)` lists the chain takes.
57
+
58
+ 🔴 **The shape of these expressions is the behaviour. Do not tidy them.**
59
+ Three ways that has already gone wrong:
60
+
61
+ - `weight > 0` is strictly greater. A miner on zero is **left out of the uid
62
+ list**, not written as a zero. Writing an explicit zero is a different
63
+ statement to the chain.
64
+ - `share = w / total` first, then `share * U16_MAX` -- **not**
65
+ `w * U16_MAX / total`. Those are not equal in floating point.
66
+ - `int(...)` truncates, it does not round. The evidence is on chain: in
67
+ snapshot 122 the burn address holds 0.9 of the total, and
68
+ `0.9 * 65535 == 58981.5` exactly. `int` gives 58981, `round` gives 58982 --
69
+ **switching to `round()` rewrites a value that is already on chain.**
70
+ Truncation is also why the u16 values sum to slightly under 65535 (65533
71
+ for that snapshot); the chain accepts the shortfall.
72
+
73
+ ⚠️ Both former copies illustrated this with `1/3 → int(21844.999…) = 21844`.
74
+ That example is wrong -- `(1/3) * 65535` is exactly `21845.0` and three
75
+ equal shares sum to exactly 65535 -- and being wrong, it invited someone
76
+ to "correct" the code to match it. The property is real; that
77
+ illustration never was.
78
+
79
+ Hotkeys present in `weights_raw` but absent from `hotkeys` (a miner who
80
+ deregistered) are dropped, and what remains is renormalised over its own
81
+ total. That is the behaviour in production; callers that want to notice it
82
+ should diff the two key sets themselves before calling.
83
+
84
+ Args:
85
+ weights_raw: shares keyed by hotkey, as the backend computed them.
86
+ hotkeys: the metagraph's hotkey list. **The index is the uid.**
87
+
88
+ Returns:
89
+ The uid list, the u16 list, and the per-entry log lines. Empty lists
90
+ when nothing has a positive weight -- the caller must not send an
91
+ extrinsic in that case, and must treat it as an event worth reporting:
92
+ it means no miner is being paid this round.
93
+ """
94
+ positive: dict[int, float] = {}
95
+ detail: list[str] = []
96
+ for uid, hotkey in enumerate(hotkeys):
97
+ weight = weights_raw.get(hotkey, 0.0)
98
+ if weight > 0:
99
+ positive[uid] = weight
100
+ detail.append(f" uid={uid:3d} hotkey={hotkey[:12]}... raw={weight:.6f}")
101
+
102
+ if not positive:
103
+ return NormalizedWeights([], [], ["no positive weights"])
104
+
105
+ total = sum(positive.values())
106
+ normed = {uid: weight / total for uid, weight in positive.items()}
107
+
108
+ uids = list(normed.keys())
109
+ weights = [int(share * U16_MAX) for share in normed.values()]
110
+
111
+ detail.append(f" raw total={total:.6f}, normalized to sum=1.0")
112
+ detail.extend(
113
+ f" → uid={uid:3d} u16={w:5d} ({normed[uid]:.6f})"
114
+ for uid, w in zip(uids, weights, strict=True)
115
+ )
116
+ return NormalizedWeights(uids, weights, detail)