openroboto-protocol 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OpenRoboto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,338 @@
1
+ Metadata-Version: 2.4
2
+ Name: openroboto-protocol
3
+ Version: 0.1.0
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
+ Keywords: bittensor,openroboto,subnet,protocol
6
+ Author: OpenRoboto
7
+ Author-email: OpenRoboto <cameron@moonshotcommons.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
15
+ Classifier: Typing :: Typed
16
+ Requires-Dist: pydantic>=2.9,<3 ; extra == 'schemas'
17
+ Requires-Python: >=3.11
18
+ Project-URL: Homepage, https://github.com/openroboto-ai/openroboto-protocol
19
+ Project-URL: Issues, https://github.com/openroboto-ai/openroboto-protocol/issues
20
+ Provides-Extra: schemas
21
+ Description-Content-Type: text/markdown
22
+
23
+ # openroboto-protocol
24
+
25
+ The shared contract between the OpenRoboto backend and everything that talks to it —
26
+ miners, the evaluation worker, and the web frontend. Bittensor **netuid 80**.
27
+
28
+ ## Install
29
+
30
+ Pin the exact version. A floating range means two sides of the subnet can resolve
31
+ to different code, which is the failure this package was created to prevent.
32
+
33
+ ```bash
34
+ uv add "openroboto-protocol==0.1.0"
35
+ # or
36
+ pip install "openroboto-protocol==0.1.0"
37
+ ```
38
+
39
+ ```python
40
+ from openroboto_protocol.seed import derive_seed, verify_seed
41
+
42
+ seed = derive_seed(block_hash, round_num, drand_random)
43
+ ```
44
+
45
+ Python **3.11+** (miners and the evaluator run 3.11, the backend runs 3.12; CI
46
+ tests both). Ships `py.typed`, so your `mypy` sees the real types.
47
+
48
+ **The base install has zero dependencies** and CI enforces that from the built
49
+ wheel's metadata, not from a promise in a comment. A miner installs this to derive a
50
+ seed and decode a commitment; that must not cost a `pydantic-core` wheel build on a
51
+ GPU box. Only `schemas.py` needs pydantic, and only the backend needs `schemas.py`:
52
+
53
+ ```bash
54
+ uv add "openroboto-protocol[schemas]==0.1.0" # backend only
55
+ ```
56
+
57
+ ## Why this package exists
58
+
59
+ The seed derivation, the commitment codec and the status vocabulary are used by both
60
+ sides of the subnet. They used to live as hand-copied files in four separate
61
+ repositories, with no version number and no check that the copies agreed. They had
62
+ already drifted.
63
+
64
+ If the seed derivation changes, **every historical evaluation becomes
65
+ irreproducible**. That is not hypothetical — this package exists because it was one
66
+ copy-paste away from happening.
67
+
68
+ ## What belongs here
69
+
70
+ | Module | Contract | Who needs both sides to agree |
71
+ | --- | --- | --- |
72
+ | `seed.py` | Seed derivation — block hash + round + drand randomness → uint32 | Backend derives it, miners verify it |
73
+ | `commitment.py` | Commitment payload encode / decode | Miners write it on chain, backend reads it |
74
+ | `model_hash.py` | Model fingerprinting | Both compute it; a mismatch rejects a submission |
75
+ | `model_format.py` | What a submittable checkpoint must contain | Miners export to it, the evaluator rejects against it |
76
+ | `status.py` | Task status and stage vocabulary | Backend, worker and frontend all render it |
77
+ | `schemas.py` | Request / response models for every API endpoint | Backend serves them, worker and CLI consume them |
78
+ | `constants.py` | `CHAMPION_MARGIN`, `REQUIRED_ENVS`, … | Ranking and admission both read them |
79
+
80
+ `model_format.py` exists because miners currently have to clone a *second*
81
+ repository to check their model before paying the submission fee:
82
+
83
+ ```
84
+ uv run libero_eval/check_model.py --model <output_dir> --config pi05_libero
85
+ ```
86
+
87
+ Getting it wrong burns TAO for nothing — the evaluator rejects bare LoRA
88
+ adapters at a pre-eval check. The rule is shared, so it belongs here, and
89
+ `openroboto check` reads it from this package.
90
+
91
+ **What never belongs here:** I/O of any kind, database access, secrets, backend
92
+ business logic. A pure function can be proven identical on both sides; a function
93
+ that makes an HTTP call cannot.
94
+
95
+ ## Versioning is a promise
96
+
97
+ The version number *is* the contract version.
98
+
99
+ > ### ⚠️ `0.x` — the promise is not in force yet
100
+ >
101
+ > While the version is `0.x`, **compatibility is not promised**. The shapes in
102
+ > `schemas.py` and the vocabularies in `status.py` may still change without a
103
+ > major bump.
104
+ >
105
+ > This is deliberate, and it ends on a specific event, not on a date:
106
+ > **`openroboto-backend` and `openroboto-cli` have not picked their launch
107
+ > versions yet.** Neither of them can even install this package today — the
108
+ > backend still carries three hand-copied mirrors (`app/domain/worker_reports.py`,
109
+ > `app/domain/reasons.py`, the copied block in `app/api/envelope.py`), which is
110
+ > the very drift this package exists to end. Freezing a contract that has never
111
+ > been consumed would freeze whatever shape it happens to have, not the shape
112
+ > integration proves it needs.
113
+ >
114
+ > **`1.0.0` ships when backend and CLI lock their launch versions against it.**
115
+ > From that release on, the table below is binding and going back to `0.x`
116
+ > is not an option — `tests/test_version.py` enforces exactly that.
117
+
118
+ | Bump | Meaning |
119
+ | --- | --- |
120
+ | `patch` | Bug fix, behaviour unchanged |
121
+ | `minor` | New optional field. Old data missing the key **must** have a default |
122
+ | `major` | Breaking change. Requires an on-chain data migration plan and review |
123
+
124
+ Consumers pin an exact version (`openroboto-protocol==0.1.0`). Floating versions are
125
+ rejected in CI, as is any vendored copy of this code.
126
+
127
+ ## What consumers must add to their own CI
128
+
129
+ Publishing this package does not, by itself, stop the drift — a repository can
130
+ install it *and* keep its old hand-copied `protocol/` directory, import the copy,
131
+ and nobody notices until an evaluation stops reproducing. That already happened:
132
+ `protocol/types.py` had drifted 105 lines and `payment.py` 313 lines across four
133
+ repositories before this package existed.
134
+
135
+ Both checks are now wired up, not just written down:
136
+ `openroboto-cli/.github/workflows/protocol-guards.yml` and the `ci` job of
137
+ `openroboto-backend/.github/workflows/ci.yml`. Copy them from there into any new
138
+ consumer.
139
+
140
+ ### 1. No vendored copy
141
+
142
+ ```yaml
143
+ - name: No vendored protocol copy
144
+ run: |
145
+ copies="$(git ls-files '*protocol/*.py')"
146
+ if [ -n "$copies" ]; then
147
+ echo "::error::vendored copy of openroboto-protocol found:"
148
+ echo "$copies"
149
+ echo "delete it and import openroboto_protocol instead"
150
+ exit 1
151
+ fi
152
+ ```
153
+
154
+ The `*protocol/*.py` pathspec catches nested copies too (`backend/protocol/status.py`
155
+ in the old prototype). It deliberately matches only `.py` files, so a `docs/protocol/`
156
+ directory of prose does not trip it.
157
+
158
+ That snippet is what `openroboto-backend` runs, where it passes. `openroboto-cli`
159
+ cannot run it yet: its `protocol/{__init__,seed,types}.py` are still on disk, and
160
+ that repository's own rule is that files inherited from `openroboto-subnet` are never
161
+ deleted, only stopped being used (`openroboto-cli/SCOPE.md`). So the cli variant
162
+ compares the file list against an explicit grandfathered set instead of requiring it
163
+ to be empty — see the workflow. Two properties matter: a **fourth** copy anywhere in
164
+ the repository is still red, and the day those three files are archived the list stops
165
+ matching and the exemption has to be deleted along with them. It is not
166
+ `continue-on-error` and it is not scoped to `src/` — a check that cannot fail on the
167
+ files it was written for is decoration.
168
+
169
+ The imports were the actual leak, and they are gone: nothing in `openroboto-cli`
170
+ imports `protocol.seed` any more (the two docs that told miners to do so now say
171
+ `openroboto_protocol.seed`), and `protocol/seed.py` was turned into a re-export shim,
172
+ so even a stale import gets this package's code rather than a copy that can drift.
173
+
174
+ Two files could not follow. `protocol/types.py` cannot become a shim because it has
175
+ **already** drifted — its `TOP_K_EMISSION_WEIGHTS` is the relative `[0.70, 0.20, 0.10]`
176
+ against this package's live absolute `(0.07, 0.02, 0.01)`, and its status vocabulary
177
+ shares no word with `status.py`. Re-exporting would silently swap those values, which
178
+ is changing behaviour, not moving code. It is deprecated whole instead. Two legacy
179
+ files still read `PI05_BASE_CHECKPOINT` and `VLAEpisode` from it, and neither symbol
180
+ exists here yet — whether they belong here at all is an open question.
181
+
182
+ `protocol/__init__.py` has no imports at all, deliberately. A package-level re-export
183
+ there would make `openroboto_protocol` a hard requirement of `from protocol.types
184
+ import …` (Python runs the parent `__init__` first), and the miners still on the old
185
+ `requirements.txt` training flow do not have this package installed. That would have
186
+ broken their training at the first line, silently, for people who are not on the team.
187
+ `openroboto-cli/tests/test_vendored_protocol.py` holds that shape in place.
188
+
189
+ ### 2. Version is pinned
190
+
191
+ ```yaml
192
+ - name: Protocol version is pinned
193
+ run: |
194
+ python3 - <<'PY'
195
+ import pathlib, sys, tomllib
196
+
197
+ reqs: list[str] = []
198
+ path = pathlib.Path("pyproject.toml")
199
+ if path.is_file():
200
+ data = tomllib.loads(path.read_text("utf-8"))
201
+ project = data.get("project", {})
202
+ reqs += project.get("dependencies", [])
203
+ for group in project.get("optional-dependencies", {}).values():
204
+ reqs += group
205
+ for group in data.get("dependency-groups", {}).values():
206
+ reqs += [item for item in group if isinstance(item, str)]
207
+ for req_file in sorted(pathlib.Path().glob("requirements*.txt")):
208
+ reqs += [
209
+ line.split("#", 1)[0].strip()
210
+ for line in req_file.read_text("utf-8").splitlines()
211
+ ]
212
+
213
+ floating = [
214
+ req for req in reqs
215
+ if req.replace(" ", "").startswith("openroboto-protocol")
216
+ and "==" not in req
217
+ ]
218
+ if floating:
219
+ print("::error::openroboto-protocol must be pinned to an exact version:")
220
+ print("\n".join(floating))
221
+ sys.exit(1)
222
+ print(f"pin ok ({len(reqs)} requirements scanned)")
223
+ PY
224
+ ```
225
+
226
+ This parses the dependency tables rather than grepping lines, which the earlier
227
+ line-based version of this snippet did. Grepping does not survive contact with real
228
+ repositories: `openroboto-backend/pyproject.toml` carries a paragraph of comments
229
+ explaining why this package is not installed yet, and `openroboto-cli/pyproject.toml`
230
+ has a `[tool.uv.sources]` entry plus its own comments — six lines total that mention
231
+ `openroboto-protocol` without a `==`, none of them a dependency. The old snippet
232
+ failed on both repositories for reasons that had nothing to do with pinning.
233
+
234
+ Ceilings, both deliberate. A `[tool.uv.sources]` path or git override is **not**
235
+ flagged: `openroboto-cli` currently needs one because this package is not published,
236
+ and the pin still applies on top of it. And the check reads `pyproject.toml` and
237
+ `requirements*.txt` only — a `constraints.txt` or a `Dockerfile` `pip install` line
238
+ slips through. Add the file to the loop if a repository grows one.
239
+
240
+ The pin check belongs in *consumer* repositories only. Run it inside
241
+ `openroboto-protocol` itself and it fires on this package's own dev group, which
242
+ depends on `openroboto-protocol[schemas]` unpinned by design.
243
+
244
+ ## Golden vectors
245
+
246
+ `tests/test_golden_vectors.py` (seeds) and `tests/test_model_golden_vectors.py`
247
+ (model fingerprints and real HuggingFace repository layouts) hold input/output pairs
248
+ that already happened on chain. They are facts, not expectations — changing one is
249
+ changing history, and that history decided who got paid.
250
+
251
+ Three historical seeds (uid 60 / 194 / 192) cannot be reproduced from their stored
252
+ inputs. They are recorded in the irreproducible list with the reason, and are
253
+ deliberately **not** golden vectors. Two model fingerprints whose HuggingFace
254
+ revisions have since been deleted are excluded for the same reason: the input is
255
+ gone, so the test could only ever be red.
256
+
257
+ New golden files must be named `tests/test_*golden_vectors.py` — that is the glob the
258
+ dedicated CI job runs.
259
+
260
+ ## Development
261
+
262
+ ```bash
263
+ uv sync # install, including dev group
264
+ bash scripts/lint.sh # ruff check + ruff format --check + mypy strict
265
+ uv run coverage run --source=src -m pytest -q # tests
266
+ uv run coverage report --fail-under=100 # the gate
267
+ uvx pre-commit install # optional: run the lint hooks before each commit
268
+ ```
269
+
270
+ CI runs `scripts/lint.sh` itself rather than repeating the commands, so green
271
+ locally means green there. `pre-commit` is deliberately not in the dev dependency
272
+ group: CI installs that group on every run and has no use for it.
273
+
274
+ `--source=src` is not optional: without it `coverage` also measures pytest itself,
275
+ the denominator inflates and the 100% gate stops meaning anything.
276
+
277
+ ## CI
278
+
279
+ `.github/workflows/ci.yml`, three jobs:
280
+
281
+ | Job | What it means when it is red |
282
+ | --- | --- |
283
+ | `golden vectors (RED = on-chain history was rewritten)` | Someone changed an input/output pair that already happened on chain. Not a test failure — a claim that the past was wrong |
284
+ | `lint + tests (py3.11)` / `(py3.12)` | ruff, `mypy --strict` on `src`, pytest, and **coverage < 100%** |
285
+ | `build (packaging gate; publish lives in release.yml)` | `uv build` broke, `twine check --strict` rejected the metadata, the wheel is missing modules or `py.typed`, or it grew an **unconditional** dependency (one without an `extra ==` marker — that one lands on every miner's machine) |
286
+
287
+ The matrix is not decoration: miners and the evaluator run 3.11 (the subnet
288
+ `Dockerfile` hardcodes `python3.11`), the backend runs 3.12, and this package is
289
+ installed into both.
290
+
291
+ The coverage gate is 100%, not 90%. The package is a few hundred lines and every
292
+ one of them decides whether an evaluation reproduces, whether a burn counts, or who
293
+ gets emissions. There is no line here that does not matter.
294
+
295
+ ## Release
296
+
297
+ A `v*` tag is the only publishing action. `.github/workflows/release.yml` takes it
298
+ from there; nobody runs `uv publish` from a laptop.
299
+
300
+ ```bash
301
+ uv version --bump patch # or minor|major — see the table above
302
+ # commit the bump in the same PR as the change it describes, let CI go green, merge
303
+ git tag v1.0.1 && git push origin v1.0.1
304
+ ```
305
+
306
+ The tag triggers, in order:
307
+
308
+ 1. **The same gates every PR runs.** `release.yml` calls `ci.yml` with
309
+ `workflow_call` instead of restating a release-specific checklist — a second copy
310
+ would be the weaker one, and nobody maintains two. Golden vectors, 3.11 + 3.12,
311
+ 100% coverage, `twine check`, and installing the built wheel to confirm every
312
+ module imports and `py.typed` shipped.
313
+ 2. **Tag/version agreement**, twice: `ci.yml` compares the tag against
314
+ `pyproject.toml`, and the publish job compares it against the artifact filenames
315
+ about to be uploaded. A consumer that pinned an exact version and got something
316
+ else voids the only reason this package exists.
317
+ 3. **A human.** The publish job runs in the `pypi` GitHub environment, which can
318
+ require a reviewer. PyPI never lets a version number be reused, not even after a
319
+ delete, so a wrong upload can only be walked forward with another patch release.
320
+ 4. **PyPI Trusted Publishing (OIDC).** No long-lived API token exists to leak. The
321
+ credential is minted for that one run, and PyPI additionally checks it came from
322
+ this repository, this workflow file and that environment. Same reasoning as
323
+ `openroboto-backend` using Workload Identity Federation instead of a service
324
+ account key.
325
+
326
+ The publish job downloads the artifact the gates produced rather than rebuilding, so
327
+ what reaches PyPI is the exact file that passed. It does not check the repository out
328
+ at all: the only thing running with the OIDC token is `uv publish`.
329
+
330
+ There is deliberately **no TestPyPI step**. The one thing it would add is "the index
331
+ accepted an upload", and `twine check --strict` plus installing the wheel and
332
+ importing every module already cover more than that. The cost is a second trusted
333
+ publisher and a second environment to keep in sync — two configurations where only
334
+ one will stay correct.
335
+
336
+ ## License
337
+
338
+ MIT
@@ -0,0 +1,316 @@
1
+ # openroboto-protocol
2
+
3
+ The shared contract between the OpenRoboto backend and everything that talks to it —
4
+ miners, the evaluation worker, and the web frontend. Bittensor **netuid 80**.
5
+
6
+ ## Install
7
+
8
+ Pin the exact version. A floating range means two sides of the subnet can resolve
9
+ to different code, which is the failure this package was created to prevent.
10
+
11
+ ```bash
12
+ uv add "openroboto-protocol==0.1.0"
13
+ # or
14
+ pip install "openroboto-protocol==0.1.0"
15
+ ```
16
+
17
+ ```python
18
+ from openroboto_protocol.seed import derive_seed, verify_seed
19
+
20
+ seed = derive_seed(block_hash, round_num, drand_random)
21
+ ```
22
+
23
+ Python **3.11+** (miners and the evaluator run 3.11, the backend runs 3.12; CI
24
+ tests both). Ships `py.typed`, so your `mypy` sees the real types.
25
+
26
+ **The base install has zero dependencies** and CI enforces that from the built
27
+ wheel's metadata, not from a promise in a comment. A miner installs this to derive a
28
+ seed and decode a commitment; that must not cost a `pydantic-core` wheel build on a
29
+ GPU box. Only `schemas.py` needs pydantic, and only the backend needs `schemas.py`:
30
+
31
+ ```bash
32
+ uv add "openroboto-protocol[schemas]==0.1.0" # backend only
33
+ ```
34
+
35
+ ## Why this package exists
36
+
37
+ The seed derivation, the commitment codec and the status vocabulary are used by both
38
+ sides of the subnet. They used to live as hand-copied files in four separate
39
+ repositories, with no version number and no check that the copies agreed. They had
40
+ already drifted.
41
+
42
+ If the seed derivation changes, **every historical evaluation becomes
43
+ irreproducible**. That is not hypothetical — this package exists because it was one
44
+ copy-paste away from happening.
45
+
46
+ ## What belongs here
47
+
48
+ | Module | Contract | Who needs both sides to agree |
49
+ | --- | --- | --- |
50
+ | `seed.py` | Seed derivation — block hash + round + drand randomness → uint32 | Backend derives it, miners verify it |
51
+ | `commitment.py` | Commitment payload encode / decode | Miners write it on chain, backend reads it |
52
+ | `model_hash.py` | Model fingerprinting | Both compute it; a mismatch rejects a submission |
53
+ | `model_format.py` | What a submittable checkpoint must contain | Miners export to it, the evaluator rejects against it |
54
+ | `status.py` | Task status and stage vocabulary | Backend, worker and frontend all render it |
55
+ | `schemas.py` | Request / response models for every API endpoint | Backend serves them, worker and CLI consume them |
56
+ | `constants.py` | `CHAMPION_MARGIN`, `REQUIRED_ENVS`, … | Ranking and admission both read them |
57
+
58
+ `model_format.py` exists because miners currently have to clone a *second*
59
+ repository to check their model before paying the submission fee:
60
+
61
+ ```
62
+ uv run libero_eval/check_model.py --model <output_dir> --config pi05_libero
63
+ ```
64
+
65
+ Getting it wrong burns TAO for nothing — the evaluator rejects bare LoRA
66
+ adapters at a pre-eval check. The rule is shared, so it belongs here, and
67
+ `openroboto check` reads it from this package.
68
+
69
+ **What never belongs here:** I/O of any kind, database access, secrets, backend
70
+ business logic. A pure function can be proven identical on both sides; a function
71
+ that makes an HTTP call cannot.
72
+
73
+ ## Versioning is a promise
74
+
75
+ The version number *is* the contract version.
76
+
77
+ > ### ⚠️ `0.x` — the promise is not in force yet
78
+ >
79
+ > While the version is `0.x`, **compatibility is not promised**. The shapes in
80
+ > `schemas.py` and the vocabularies in `status.py` may still change without a
81
+ > major bump.
82
+ >
83
+ > This is deliberate, and it ends on a specific event, not on a date:
84
+ > **`openroboto-backend` and `openroboto-cli` have not picked their launch
85
+ > versions yet.** Neither of them can even install this package today — the
86
+ > backend still carries three hand-copied mirrors (`app/domain/worker_reports.py`,
87
+ > `app/domain/reasons.py`, the copied block in `app/api/envelope.py`), which is
88
+ > the very drift this package exists to end. Freezing a contract that has never
89
+ > been consumed would freeze whatever shape it happens to have, not the shape
90
+ > integration proves it needs.
91
+ >
92
+ > **`1.0.0` ships when backend and CLI lock their launch versions against it.**
93
+ > From that release on, the table below is binding and going back to `0.x`
94
+ > is not an option — `tests/test_version.py` enforces exactly that.
95
+
96
+ | Bump | Meaning |
97
+ | --- | --- |
98
+ | `patch` | Bug fix, behaviour unchanged |
99
+ | `minor` | New optional field. Old data missing the key **must** have a default |
100
+ | `major` | Breaking change. Requires an on-chain data migration plan and review |
101
+
102
+ Consumers pin an exact version (`openroboto-protocol==0.1.0`). Floating versions are
103
+ rejected in CI, as is any vendored copy of this code.
104
+
105
+ ## What consumers must add to their own CI
106
+
107
+ Publishing this package does not, by itself, stop the drift — a repository can
108
+ install it *and* keep its old hand-copied `protocol/` directory, import the copy,
109
+ and nobody notices until an evaluation stops reproducing. That already happened:
110
+ `protocol/types.py` had drifted 105 lines and `payment.py` 313 lines across four
111
+ repositories before this package existed.
112
+
113
+ Both checks are now wired up, not just written down:
114
+ `openroboto-cli/.github/workflows/protocol-guards.yml` and the `ci` job of
115
+ `openroboto-backend/.github/workflows/ci.yml`. Copy them from there into any new
116
+ consumer.
117
+
118
+ ### 1. No vendored copy
119
+
120
+ ```yaml
121
+ - name: No vendored protocol copy
122
+ run: |
123
+ copies="$(git ls-files '*protocol/*.py')"
124
+ if [ -n "$copies" ]; then
125
+ echo "::error::vendored copy of openroboto-protocol found:"
126
+ echo "$copies"
127
+ echo "delete it and import openroboto_protocol instead"
128
+ exit 1
129
+ fi
130
+ ```
131
+
132
+ The `*protocol/*.py` pathspec catches nested copies too (`backend/protocol/status.py`
133
+ in the old prototype). It deliberately matches only `.py` files, so a `docs/protocol/`
134
+ directory of prose does not trip it.
135
+
136
+ That snippet is what `openroboto-backend` runs, where it passes. `openroboto-cli`
137
+ cannot run it yet: its `protocol/{__init__,seed,types}.py` are still on disk, and
138
+ that repository's own rule is that files inherited from `openroboto-subnet` are never
139
+ deleted, only stopped being used (`openroboto-cli/SCOPE.md`). So the cli variant
140
+ compares the file list against an explicit grandfathered set instead of requiring it
141
+ to be empty — see the workflow. Two properties matter: a **fourth** copy anywhere in
142
+ the repository is still red, and the day those three files are archived the list stops
143
+ matching and the exemption has to be deleted along with them. It is not
144
+ `continue-on-error` and it is not scoped to `src/` — a check that cannot fail on the
145
+ files it was written for is decoration.
146
+
147
+ The imports were the actual leak, and they are gone: nothing in `openroboto-cli`
148
+ imports `protocol.seed` any more (the two docs that told miners to do so now say
149
+ `openroboto_protocol.seed`), and `protocol/seed.py` was turned into a re-export shim,
150
+ so even a stale import gets this package's code rather than a copy that can drift.
151
+
152
+ Two files could not follow. `protocol/types.py` cannot become a shim because it has
153
+ **already** drifted — its `TOP_K_EMISSION_WEIGHTS` is the relative `[0.70, 0.20, 0.10]`
154
+ against this package's live absolute `(0.07, 0.02, 0.01)`, and its status vocabulary
155
+ shares no word with `status.py`. Re-exporting would silently swap those values, which
156
+ is changing behaviour, not moving code. It is deprecated whole instead. Two legacy
157
+ files still read `PI05_BASE_CHECKPOINT` and `VLAEpisode` from it, and neither symbol
158
+ exists here yet — whether they belong here at all is an open question.
159
+
160
+ `protocol/__init__.py` has no imports at all, deliberately. A package-level re-export
161
+ there would make `openroboto_protocol` a hard requirement of `from protocol.types
162
+ import …` (Python runs the parent `__init__` first), and the miners still on the old
163
+ `requirements.txt` training flow do not have this package installed. That would have
164
+ broken their training at the first line, silently, for people who are not on the team.
165
+ `openroboto-cli/tests/test_vendored_protocol.py` holds that shape in place.
166
+
167
+ ### 2. Version is pinned
168
+
169
+ ```yaml
170
+ - name: Protocol version is pinned
171
+ run: |
172
+ python3 - <<'PY'
173
+ import pathlib, sys, tomllib
174
+
175
+ reqs: list[str] = []
176
+ path = pathlib.Path("pyproject.toml")
177
+ if path.is_file():
178
+ data = tomllib.loads(path.read_text("utf-8"))
179
+ project = data.get("project", {})
180
+ reqs += project.get("dependencies", [])
181
+ for group in project.get("optional-dependencies", {}).values():
182
+ reqs += group
183
+ for group in data.get("dependency-groups", {}).values():
184
+ reqs += [item for item in group if isinstance(item, str)]
185
+ for req_file in sorted(pathlib.Path().glob("requirements*.txt")):
186
+ reqs += [
187
+ line.split("#", 1)[0].strip()
188
+ for line in req_file.read_text("utf-8").splitlines()
189
+ ]
190
+
191
+ floating = [
192
+ req for req in reqs
193
+ if req.replace(" ", "").startswith("openroboto-protocol")
194
+ and "==" not in req
195
+ ]
196
+ if floating:
197
+ print("::error::openroboto-protocol must be pinned to an exact version:")
198
+ print("\n".join(floating))
199
+ sys.exit(1)
200
+ print(f"pin ok ({len(reqs)} requirements scanned)")
201
+ PY
202
+ ```
203
+
204
+ This parses the dependency tables rather than grepping lines, which the earlier
205
+ line-based version of this snippet did. Grepping does not survive contact with real
206
+ repositories: `openroboto-backend/pyproject.toml` carries a paragraph of comments
207
+ explaining why this package is not installed yet, and `openroboto-cli/pyproject.toml`
208
+ has a `[tool.uv.sources]` entry plus its own comments — six lines total that mention
209
+ `openroboto-protocol` without a `==`, none of them a dependency. The old snippet
210
+ failed on both repositories for reasons that had nothing to do with pinning.
211
+
212
+ Ceilings, both deliberate. A `[tool.uv.sources]` path or git override is **not**
213
+ flagged: `openroboto-cli` currently needs one because this package is not published,
214
+ and the pin still applies on top of it. And the check reads `pyproject.toml` and
215
+ `requirements*.txt` only — a `constraints.txt` or a `Dockerfile` `pip install` line
216
+ slips through. Add the file to the loop if a repository grows one.
217
+
218
+ The pin check belongs in *consumer* repositories only. Run it inside
219
+ `openroboto-protocol` itself and it fires on this package's own dev group, which
220
+ depends on `openroboto-protocol[schemas]` unpinned by design.
221
+
222
+ ## Golden vectors
223
+
224
+ `tests/test_golden_vectors.py` (seeds) and `tests/test_model_golden_vectors.py`
225
+ (model fingerprints and real HuggingFace repository layouts) hold input/output pairs
226
+ that already happened on chain. They are facts, not expectations — changing one is
227
+ changing history, and that history decided who got paid.
228
+
229
+ Three historical seeds (uid 60 / 194 / 192) cannot be reproduced from their stored
230
+ inputs. They are recorded in the irreproducible list with the reason, and are
231
+ deliberately **not** golden vectors. Two model fingerprints whose HuggingFace
232
+ revisions have since been deleted are excluded for the same reason: the input is
233
+ gone, so the test could only ever be red.
234
+
235
+ New golden files must be named `tests/test_*golden_vectors.py` — that is the glob the
236
+ dedicated CI job runs.
237
+
238
+ ## Development
239
+
240
+ ```bash
241
+ uv sync # install, including dev group
242
+ bash scripts/lint.sh # ruff check + ruff format --check + mypy strict
243
+ uv run coverage run --source=src -m pytest -q # tests
244
+ uv run coverage report --fail-under=100 # the gate
245
+ uvx pre-commit install # optional: run the lint hooks before each commit
246
+ ```
247
+
248
+ CI runs `scripts/lint.sh` itself rather than repeating the commands, so green
249
+ locally means green there. `pre-commit` is deliberately not in the dev dependency
250
+ group: CI installs that group on every run and has no use for it.
251
+
252
+ `--source=src` is not optional: without it `coverage` also measures pytest itself,
253
+ the denominator inflates and the 100% gate stops meaning anything.
254
+
255
+ ## CI
256
+
257
+ `.github/workflows/ci.yml`, three jobs:
258
+
259
+ | Job | What it means when it is red |
260
+ | --- | --- |
261
+ | `golden vectors (RED = on-chain history was rewritten)` | Someone changed an input/output pair that already happened on chain. Not a test failure — a claim that the past was wrong |
262
+ | `lint + tests (py3.11)` / `(py3.12)` | ruff, `mypy --strict` on `src`, pytest, and **coverage < 100%** |
263
+ | `build (packaging gate; publish lives in release.yml)` | `uv build` broke, `twine check --strict` rejected the metadata, the wheel is missing modules or `py.typed`, or it grew an **unconditional** dependency (one without an `extra ==` marker — that one lands on every miner's machine) |
264
+
265
+ The matrix is not decoration: miners and the evaluator run 3.11 (the subnet
266
+ `Dockerfile` hardcodes `python3.11`), the backend runs 3.12, and this package is
267
+ installed into both.
268
+
269
+ The coverage gate is 100%, not 90%. The package is a few hundred lines and every
270
+ one of them decides whether an evaluation reproduces, whether a burn counts, or who
271
+ gets emissions. There is no line here that does not matter.
272
+
273
+ ## Release
274
+
275
+ A `v*` tag is the only publishing action. `.github/workflows/release.yml` takes it
276
+ from there; nobody runs `uv publish` from a laptop.
277
+
278
+ ```bash
279
+ uv version --bump patch # or minor|major — see the table above
280
+ # commit the bump in the same PR as the change it describes, let CI go green, merge
281
+ git tag v1.0.1 && git push origin v1.0.1
282
+ ```
283
+
284
+ The tag triggers, in order:
285
+
286
+ 1. **The same gates every PR runs.** `release.yml` calls `ci.yml` with
287
+ `workflow_call` instead of restating a release-specific checklist — a second copy
288
+ would be the weaker one, and nobody maintains two. Golden vectors, 3.11 + 3.12,
289
+ 100% coverage, `twine check`, and installing the built wheel to confirm every
290
+ module imports and `py.typed` shipped.
291
+ 2. **Tag/version agreement**, twice: `ci.yml` compares the tag against
292
+ `pyproject.toml`, and the publish job compares it against the artifact filenames
293
+ about to be uploaded. A consumer that pinned an exact version and got something
294
+ else voids the only reason this package exists.
295
+ 3. **A human.** The publish job runs in the `pypi` GitHub environment, which can
296
+ require a reviewer. PyPI never lets a version number be reused, not even after a
297
+ delete, so a wrong upload can only be walked forward with another patch release.
298
+ 4. **PyPI Trusted Publishing (OIDC).** No long-lived API token exists to leak. The
299
+ credential is minted for that one run, and PyPI additionally checks it came from
300
+ this repository, this workflow file and that environment. Same reasoning as
301
+ `openroboto-backend` using Workload Identity Federation instead of a service
302
+ account key.
303
+
304
+ The publish job downloads the artifact the gates produced rather than rebuilding, so
305
+ what reaches PyPI is the exact file that passed. It does not check the repository out
306
+ at all: the only thing running with the OIDC token is `uv publish`.
307
+
308
+ There is deliberately **no TestPyPI step**. The one thing it would add is "the index
309
+ accepted an upload", and `twine check --strict` plus installing the wheel and
310
+ importing every module already cover more than that. The cost is a second trusted
311
+ publisher and a second environment to keep in sync — two configurations where only
312
+ one will stay correct.
313
+
314
+ ## License
315
+
316
+ MIT