Skip to content

Commit 8843378

Browse files
authored
Merge pull request #8 from RendixNetwork/feat/consensus-surface
T1+T2 — Consensus, liveness & anti-abuse: pinned corpus, an eval server that can't wedge, and the freeze cheat closed
2 parents b959254 + 87db8b2 commit 8843378

50 files changed

Lines changed: 6652 additions & 521 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,16 @@ per-clip seed**; each generation is scored against the **real continuation** wit
4949

5050
The challenger is crowned only if it is **confidently** better: the per-clip advantage
5151
`king_distance − challenger_distance` is bootstrapped, and the crown passes iff the lower-confidence
52-
bound `lcb > delta_threshold`. All duel parameters (`LEOMA_DELTA_THRESHOLD`, `LEOMA_ALPHA`,
53-
`LEOMA_N_BOOTSTRAP`, `LEOMA_DUEL_METRIC`, `LEOMA_DUEL_N_CLIPS`) **must match across validators** for
54-
consensus. The block hash is unpredictable until mined, so miners cannot overfit to the test set, yet
55-
every validator reproduces the identical verdict.
52+
bound `lcb > delta_threshold`.
53+
54+
Every input that can change a verdict — the corpus, the prompt, the frame count, the resolution, the
55+
metric, the threshold — is pinned in **`chain.toml`**, hashed into a `consensus_digest` that is sent
56+
with each eval request and **echoed back in the verdict**. A validator running a different config
57+
cannot quietly disagree with the rest of the subnet: the mismatch is refused at the door. The held-out
58+
clips come from a **digest-pinned corpus manifest** (not a live bucket listing), with each clip's
59+
window and ground-truth hash fixed offline, so two validators provably grade the same exam. The block
60+
hash is unpredictable until mined, so miners cannot overfit to the test set, yet every validator
61+
reproduces the identical verdict.
5662

5763
---
5864

@@ -94,6 +100,35 @@ leoma servers eval-server # FastAPI on EVAL_SERVER_PORT (default 9000)
94100
The validator reaches it over `EVAL_SERVER_URL` (default `http://localhost:9000`, usually an SSH
95101
tunnel). One duel runs at a time. See `ecosystem.eval.config.js` for a PM2 launcher.
96102

103+
Before a new eval box is allowed to duel, prove it decodes the pinned corpus byte-identically:
104+
105+
```bash
106+
leoma corpus verify --sample 4
107+
```
108+
109+
A box whose ffmpeg produces even slightly different pixels measures every distance against different
110+
ground truth — silently, confidently, and wrongly. This takes a minute and rules that out.
111+
112+
---
113+
114+
## Corpus (subnet operator)
115+
116+
The duel's held-out clips come from a **pinned manifest**, not a live bucket listing. The manifest
117+
fixes which videos, the window inside each one, and the hash of the decoded ground truth — so every
118+
validator provably grades the same exam. Building it once, offline, is what removes the whole class of
119+
"two honest validators disagree" bugs: nothing is scene-detected, listed or skipped at duel time.
120+
121+
```bash
122+
leoma corpus build-manifest --corpus-id leoma-corpus-v1 # decides windows, hashes truth
123+
leoma corpus publish-manifest manifest.json # uploads; prints the digest
124+
# paste that digest into chain.toml [corpus].manifest_digest, then ship it
125+
```
126+
127+
**Until `[corpus].manifest_digest` and `[seed].seed_digest` are pinned, validators refuse to duel and
128+
burn 100% to UID 0.** That is deliberate: an unpinned corpus is not reproducible, and an unevaluated
129+
first challenger must never be crowned. Rotate the corpus by rebuilding with a new `corpus-id` and
130+
re-pinning — a version bump, auditable in git.
131+
97132
---
98133

99134
## Miner setup

docker-compose.yml

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,7 @@ services:
2020
WALLET_NAME: ${WALLET_NAME:-default}
2121
HOTKEY_NAME: ${HOTKEY_NAME:-default}
2222
EVAL_SERVER_URL: ${EVAL_SERVER_URL:-http://eval-server:9000}
23-
# Duel parameters — identical across validators for consensus.
24-
LEOMA_DUEL_METRIC: ${LEOMA_DUEL_METRIC:-lpips}
25-
LEOMA_DUEL_N_CLIPS: ${LEOMA_DUEL_N_CLIPS:-32}
26-
LEOMA_DELTA_THRESHOLD: ${LEOMA_DELTA_THRESHOLD:-0.0025}
27-
LEOMA_ALPHA: ${LEOMA_ALPHA:-0.001}
28-
LEOMA_N_BOOTSTRAP: ${LEOMA_N_BOOTSTRAP:-10000}
23+
# Duel parameters live in chain.toml (the consensus surface), not here.
2924
# This validator's own state bucket (durable king state).
3025
OBJECT_STORAGE_BACKEND: ${OBJECT_STORAGE_BACKEND:-r2}
3126
R2_OWN_BUCKET: ${R2_OWN_BUCKET:-}

ecosystem.config.js

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,7 @@ module.exports = {
2222
// Where duels are dispatched (SSH-tunnel to the GPU eval box).
2323
EVAL_SERVER_URL: "http://localhost:9000",
2424

25-
// Duel parameters — MUST match across validators for consensus.
26-
LEOMA_DUEL_METRIC: "lpips",
27-
LEOMA_DUEL_N_CLIPS: "32",
28-
LEOMA_DELTA_THRESHOLD: "0.0025",
29-
LEOMA_ALPHA: "0.001",
30-
LEOMA_N_BOOTSTRAP: "10000",
25+
// Duel parameters live in chain.toml (the consensus surface), not here.
3126
LEOMA_KING_CHAIN_SIZE: "5",
3227
LEOMA_WEIGHT_INTERVAL: "300",
3328
LEOMA_BURN_UID: "0",

env.validator.example

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ NETUID=99
1313
# Eval server the validator dispatches duels to (SSH-tunnel to the GPU box).
1414
EVAL_SERVER_URL=http://localhost:9000
1515

16-
# Duel parameters — keep identical across all validators for consensus.
17-
LEOMA_DUEL_METRIC=lpips
18-
LEOMA_DUEL_N_CLIPS=32
19-
LEOMA_DELTA_THRESHOLD=0.0025
20-
LEOMA_ALPHA=0.001
21-
LEOMA_N_BOOTSTRAP=10000
16+
# Duel parameters are NOT set here any more. The metric, clip count, threshold,
17+
# alpha, bootstrap count and every generation knob are the CONSENSUS SURFACE and
18+
# live in chain.toml, hashed into a consensus_digest that is sent with every eval
19+
# request and echoed in every verdict. An env var is per-box, and a per-box exam is
20+
# not consensus: one validator with a stray LEOMA_DELTA_THRESHOLD used to be enough
21+
# to silently disagree with the rest of the subnet forever.
2222
LEOMA_KING_CHAIN_SIZE=5
2323
LEOMA_WEIGHT_INTERVAL=300
2424
LEOMA_BURN_UID=0

leoma/app/validator/dashboard.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,21 @@ def build_dashboard(
6363

6464
king = _king_entry(state.king, uid_map) if state.king else {}
6565

66+
# The duel currently on the GPU. The dashboard used to go dark for the entire
67+
# length of a duel — hours in which the most interesting thing in the subnet was
68+
# happening and the site showed nothing at all.
69+
live = None
70+
if state.inflight:
71+
slot = dict(state.inflight)
72+
live = {
73+
"eval_id": slot.get("eval_id"),
74+
"hotkey": slot.get("hotkey"),
75+
"uid": uid_map.get(slot.get("hotkey", "")),
76+
"model_repo": slot.get("model_repo"),
77+
"model_digest": slot.get("model_digest"),
78+
"dispatched_block": slot.get("dispatched_block"),
79+
}
80+
6681
return {
6782
"updated_at": updated_at,
6883
"chain": chain_meta,
@@ -72,6 +87,11 @@ def build_dashboard(
7287
"stats": dict(state.stats),
7388
"queue": list(queue or []),
7489
"history": list(state.history),
90+
"live": live,
91+
# Why the validator is not crowning anyone, if it isn't: an unpinned corpus, a
92+
# missing seed digest, a stale eval box. Without this the operator sees a
93+
# subnet burning 100% to UID 0 and no reason anywhere.
94+
"degraded": state.degraded,
7595
}
7696

7797

leoma/app/validator/failures.py

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,35 @@
55
queue with ``requeue_front``/``retry_count``; we need a *decision function* over a
66
stateless work list. This module is that decision.
77
8-
Three classes, and the distinction matters:
8+
Four classes, and the distinctions all matter:
99
1010
* ``BUSY`` — a property of the SERVER (the eval box is running someone else's
1111
duel). Not a failure; consumes no attempt. The caller must
1212
``break`` (continuing would just 409 N more times).
13-
* ``TRANSIENT`` — a property of the ENVIRONMENT (network, disk, GPU, our corpus).
14-
Retry with block-based backoff; quarantine only after the attempt
15-
budget is exhausted.
13+
* ``TRANSIENT`` — a property of the ENVIRONMENT (network, disk, GPU). Retry with
14+
block-based backoff; quarantine only after the attempt budget is
15+
exhausted.
1616
* ``PERMANENT`` — a property of the ARTIFACT (the repo 404s, the weights won't
1717
load, the arch is wrong). ``repo@digest`` is immutable, so this
1818
can never succeed; quarantine it.
19+
* ``LOCAL`` — a property of **THIS VALIDATOR** (our corpus doesn't match the
20+
pinned manifest, our eval box runs a stale chain.toml). Costs the
21+
challenger **nothing**: no attempt, no backoff, no quarantine. The
22+
caller must ``break`` — every challenger would hit the identical
23+
wall, including the king.
1924
2025
**Design rule: when in doubt, TRANSIENT.** A misclassified transient costs four
21-
retries. A misclassified permanent locks a legitimate miner out of an artifact.
22-
That asymmetry is why auth errors are deliberately *not* permanent: a validator's
23-
own token misconfiguration would otherwise quarantine every miner on the subnet.
26+
retries. A misclassified permanent locks a legitimate miner out of an artifact. That
27+
asymmetry is why auth errors are deliberately *not* permanent: a validator's own
28+
token misconfiguration would otherwise quarantine every miner on the subnet.
29+
30+
**LOCAL exists because TRANSIENT is not actually harmless.** The attempt ledger
31+
quarantines an artifact once its attempts are ``exhausted``, whatever the class. So a
32+
validator whose *own* corpus was broken would fail every duel transiently, four times
33+
each, and then quarantine **every miner on the subnet** — permanently locking out
34+
honest models because of its own misconfiguration. Faults that are ours must therefore
35+
not touch the challenger's ledger at all. They are a reason to stop dueling, not a
36+
reason to blame whoever happened to be next in the queue.
2437
"""
2538
from __future__ import annotations
2639

@@ -32,6 +45,7 @@ class ErrorClass(str, Enum):
3245
BUSY = "busy"
3346
TRANSIENT = "transient"
3447
PERMANENT = "permanent"
48+
LOCAL = "local"
3549

3650

3751
@dataclass(frozen=True)
@@ -48,6 +62,26 @@ def is_permanent(self) -> bool:
4862
def is_transient(self) -> bool:
4963
return self.kind is ErrorClass.TRANSIENT
5064

65+
@property
66+
def is_local(self) -> bool:
67+
return self.kind is ErrorClass.LOCAL
68+
69+
70+
# OUR fault, not the challenger's. These would fail identically for every model on
71+
# the subnet — including the reigning king — so they can never be evidence about a
72+
# particular challenger. Checked FIRST, before anything else can claim them.
73+
_LOCAL_SIGNS: tuple[tuple[str, str], ...] = (
74+
("corpus_integrity", "corpus_integrity"),
75+
("consensus_config", "consensus_config"),
76+
("consensus_mismatch", "consensus_mismatch"),
77+
("consensus_echo_mismatch", "consensus_echo_mismatch"),
78+
("code_mismatch", "code_mismatch"),
79+
("does not match the manifest", "corpus_integrity"),
80+
("decoded ground truth does not match", "corpus_integrity"),
81+
("corpus manifest digest mismatch", "corpus_integrity"),
82+
("manifest_digest is not pinned", "consensus_config"),
83+
("different consensus surface", "consensus_mismatch"),
84+
)
5185

5286
# Substring -> (class, reason). Order matters: the first match wins, so the
5387
# PERMANENT artifact signatures are checked before the generic transient ones.
@@ -136,6 +170,9 @@ def classify_remote(message: str, reason: str = "") -> DuelFailure:
136170

137171
token = reason.strip().lower()
138172
if token:
173+
for _, r in _LOCAL_SIGNS:
174+
if token == r:
175+
return DuelFailure(ErrorClass.LOCAL, r, message)
139176
for _, r in _PERMANENT_SIGNS:
140177
if token == r:
141178
return DuelFailure(ErrorClass.PERMANENT, r, message)
@@ -145,6 +182,10 @@ def classify_remote(message: str, reason: str = "") -> DuelFailure:
145182
if token.startswith("watchdog_stall"):
146183
return DuelFailure(ErrorClass.TRANSIENT, token, message)
147184

185+
hit = _match(text, _LOCAL_SIGNS)
186+
if hit:
187+
return DuelFailure(ErrorClass.LOCAL, hit, message)
188+
148189
hit = _match(text, _PERMANENT_SIGNS)
149190
if hit:
150191
return DuelFailure(ErrorClass.PERMANENT, hit, message)
@@ -165,6 +206,23 @@ def classify(exc: BaseException) -> DuelFailure:
165206
if isinstance(exc, EvalJobFailed):
166207
return classify_remote(exc.detail, exc.reason)
167208

209+
# The typed eval errors say who is at fault directly — no substring guessing.
210+
from leoma.eval.errors import (
211+
ChallengerFault,
212+
ConsensusConfigError,
213+
CorpusIntegrityError,
214+
DuelCancelled,
215+
)
216+
217+
if isinstance(exc, (CorpusIntegrityError, ConsensusConfigError)):
218+
return DuelFailure(ErrorClass.LOCAL, exc.reason, str(exc))
219+
if isinstance(exc, DuelCancelled):
220+
# Checked BEFORE ChallengerFault would be, and deliberately transient: a
221+
# watchdog stall or an operator's DELETE says nothing about the model.
222+
return DuelFailure(ErrorClass.TRANSIENT, exc.reason, str(exc))
223+
if isinstance(exc, ChallengerFault):
224+
return DuelFailure(ErrorClass.PERMANENT, exc.reason, str(exc))
225+
168226
type_name = type(exc).__name__.lower()
169227
message = str(exc)
170228
text = f"{type_name} {message}".lower()
@@ -173,6 +231,10 @@ def classify(exc: BaseException) -> DuelFailure:
173231
reason = _match(text, _TRANSIENT_SIGNS) or "eval_unreachable"
174232
return DuelFailure(ErrorClass.TRANSIENT, reason, message)
175233

234+
hit = _match(text, _LOCAL_SIGNS)
235+
if hit:
236+
return DuelFailure(ErrorClass.LOCAL, hit, message)
237+
176238
hit = _match(text, _PERMANENT_SIGNS)
177239
if hit:
178240
return DuelFailure(ErrorClass.PERMANENT, hit, message)

0 commit comments

Comments
 (0)