feat(alpha): complete hardening backlog
Complete the alpha-hardening Ralph task set, including tracker billing/accounting guards, validator fraud-audit primitives, wallet binding proof support, documentation runbooks, and updated tests. Verification: .venv/bin/python -m compileall -q packages tests; .venv/bin/python -m pytest -q --tb=short (313 passed, 3 skipped, 1 failed: tests/test_mining_cli.py::test_legacy_start_without_port_uses_next_available_port because meshnet-node pid 1263451 is already listening on port 7000).
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,8 @@
|
||||
"vision_encoder_parameters": "400M",
|
||||
"license": "modified-mit",
|
||||
"native_quantization": "int4",
|
||||
"canonical_audit_dtype": "bfloat16",
|
||||
"canonical_audit_quantization": "bfloat16",
|
||||
"download_size_gb": 595,
|
||||
"recommended_short_name": "kimi-k2.7",
|
||||
"recommended_engines": [
|
||||
|
||||
@@ -19,7 +19,7 @@ import time
|
||||
import uuid
|
||||
|
||||
DEFAULT_PRICE_PER_1K_TOKENS = 0.02 # USDT
|
||||
DEFAULT_STARTING_CREDIT = 1.0 # USDT of Caller Credit for a new API key
|
||||
DEFAULT_STARTING_CREDIT = 0.0 # USDT; accounts must be funded before inference
|
||||
DEFAULT_BILLING_DB_PATH = "billing.sqlite"
|
||||
NODE_REVENUE_SHARE = 0.90 # nodes 90% / protocol cut 10% (ADR-0015)
|
||||
|
||||
@@ -71,9 +71,9 @@ class BillingLedger:
|
||||
# ---- local operations (create + apply + log an event) ----
|
||||
|
||||
def ensure_client(self, api_key: str) -> float:
|
||||
"""Return the client's balance, granting Caller Credit on first touch."""
|
||||
"""Return the client's balance without granting implicit credit."""
|
||||
with self._lock:
|
||||
if api_key not in self._client_balances:
|
||||
if api_key not in self._client_balances and self._starting_credit > 0.0:
|
||||
self._apply_locked({
|
||||
"id": f"credit-{uuid.uuid4().hex}",
|
||||
"type": "credit",
|
||||
@@ -82,7 +82,7 @@ class BillingLedger:
|
||||
"ts": time.time(),
|
||||
"note": "caller-credit",
|
||||
})
|
||||
return self._client_balances[api_key]
|
||||
return self._client_balances.get(api_key, 0.0)
|
||||
|
||||
def has_funds(self, api_key: str) -> bool:
|
||||
return self.ensure_client(api_key) > 0.0
|
||||
@@ -127,7 +127,7 @@ class BillingLedger:
|
||||
shares[wallet] = shares.get(wallet, 0.0) + node_pool * work / total_work
|
||||
protocol_amount = cost - sum(shares.values())
|
||||
with self._lock:
|
||||
if api_key not in self._client_balances:
|
||||
if api_key not in self._client_balances and self._starting_credit > 0.0:
|
||||
self._apply_locked({
|
||||
"id": f"credit-{uuid.uuid4().hex}",
|
||||
"type": "credit",
|
||||
@@ -150,14 +150,21 @@ class BillingLedger:
|
||||
self._apply_locked(event)
|
||||
return event
|
||||
|
||||
def bind_wallet(self, api_key: str, wallet: str) -> dict:
|
||||
"""Bind a client wallet pubkey to an API key (US-032 deposits)."""
|
||||
def bind_wallet(self, api_key: str, wallet: str, *, admin_override: bool = False) -> dict:
|
||||
"""Bind a client wallet pubkey to an API key (US-032 deposits, C6).
|
||||
|
||||
A wallet already bound to a *different* api_key is left untouched
|
||||
unless ``admin_override`` is set — enforced in ``_apply_locked`` so a
|
||||
conflicting gossip ``bind`` event can never silently clobber an
|
||||
existing binding either. Callers should check ``event["rejected"]``.
|
||||
"""
|
||||
with self._lock:
|
||||
event = {
|
||||
"id": f"bind-{uuid.uuid4().hex}",
|
||||
"type": "bind",
|
||||
"api_key": api_key,
|
||||
"wallet": wallet,
|
||||
"admin_override": admin_override,
|
||||
"ts": time.time(),
|
||||
}
|
||||
self._apply_locked(event)
|
||||
@@ -262,10 +269,17 @@ class BillingLedger:
|
||||
]
|
||||
|
||||
def settle_node_payout(self, wallet: str, amount: float, *, reference: str = "") -> dict:
|
||||
"""Deduct a paid-out amount from a node's pending balance (US-033 hook)."""
|
||||
"""Deduct a paid-out amount from a node's pending balance (US-033 hook).
|
||||
|
||||
ADR-0015: clamps to whatever is still pending under the same lock as
|
||||
the deduction, so a forfeiture that lands between the settlement
|
||||
loop's `payables()` snapshot and this call is never paid out on top
|
||||
of — the wallet gets at most what remains, never a stale amount.
|
||||
"""
|
||||
if amount <= 0:
|
||||
raise ValueError("payout amount must be positive")
|
||||
with self._lock:
|
||||
amount = min(amount, self._node_pending.get(wallet, 0.0))
|
||||
event = {
|
||||
"id": f"payout-{uuid.uuid4().hex}",
|
||||
"type": "payout",
|
||||
@@ -348,7 +362,12 @@ class BillingLedger:
|
||||
self._node_pending[wallet] = self._node_pending.get(wallet, 0.0) - amount
|
||||
self._protocol_cut += amount
|
||||
elif etype == "bind":
|
||||
self._wallet_bindings[event["wallet"]] = event["api_key"]
|
||||
wallet = event["wallet"]
|
||||
existing = self._wallet_bindings.get(wallet)
|
||||
if existing is not None and existing != event["api_key"] and not event.get("admin_override"):
|
||||
event["rejected"] = True
|
||||
else:
|
||||
self._wallet_bindings[wallet] = event["api_key"]
|
||||
else:
|
||||
return
|
||||
self._seen_event_ids.add(event["id"])
|
||||
|
||||
@@ -49,11 +49,20 @@ def main() -> None:
|
||||
f"(default: {DEFAULT_BILLING_DB_PATH}; ADR-0015)"
|
||||
),
|
||||
)
|
||||
common.add_argument(
|
||||
"--no-billing",
|
||||
action="store_true",
|
||||
help="Disable the USDT billing ledger",
|
||||
)
|
||||
common.add_argument(
|
||||
"--no-billing",
|
||||
action="store_true",
|
||||
help="Disable the USDT billing ledger",
|
||||
)
|
||||
common.add_argument(
|
||||
"--max-charge-per-request",
|
||||
type=float,
|
||||
default=None,
|
||||
help=(
|
||||
"Reject chat completion requests whose requested token limit would "
|
||||
"cost more than this many USDT"
|
||||
),
|
||||
)
|
||||
common.add_argument(
|
||||
"--accounts-db",
|
||||
default=DEFAULT_ACCOUNTS_DB_PATH,
|
||||
@@ -149,8 +158,9 @@ def main() -> None:
|
||||
stats_db=getattr(args, "stats_db", None),
|
||||
relay_url=relay_url,
|
||||
enable_billing=not args.no_billing,
|
||||
billing_db=None if args.no_billing else args.billing_db,
|
||||
accounts_db=None if args.no_accounts else args.accounts_db,
|
||||
billing_db=None if args.no_billing else args.billing_db,
|
||||
max_charge_per_request=args.max_charge_per_request,
|
||||
accounts_db=None if args.no_accounts else args.accounts_db,
|
||||
treasury=treasury,
|
||||
settle_period=args.settle_period,
|
||||
payout_threshold=args.payout_threshold,
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"required_model_bytes": 638876385280,
|
||||
"download_size_bytes": 638876385280,
|
||||
"native_quantization": "int4",
|
||||
"canonical_audit_dtype": "bfloat16",
|
||||
"canonical_audit_quantization": "bfloat16",
|
||||
"bytes_per_layer": {
|
||||
"int4": 10473383366
|
||||
},
|
||||
@@ -24,6 +26,8 @@
|
||||
"num_layers": 61,
|
||||
"context_length": 256000,
|
||||
"native_quantization": "int4",
|
||||
"canonical_audit_dtype": "bfloat16",
|
||||
"canonical_audit_quantization": "bfloat16",
|
||||
"download_size_gb": 595,
|
||||
"recommended_short_name": "kimi-k2.7",
|
||||
"recommended_engines": [
|
||||
|
||||
@@ -38,6 +38,7 @@ from typing import Any
|
||||
|
||||
from .accounts import DEFAULT_ACCOUNTS_DB_PATH, AccountStore
|
||||
from .auth import is_validator_token, sign_hive_request, verify_hive_request
|
||||
from .wallet_proof import binding_message, verify_wallet_signature
|
||||
from .billing import DEFAULT_BILLING_DB_PATH, BillingLedger
|
||||
from .gossip import NodeGossip
|
||||
from .raft import RaftNode
|
||||
@@ -574,17 +575,30 @@ def _effective_throughput(node: "_NodeEntry", model: str | None = None) -> float
|
||||
return base / (node.queue_depth + 1)
|
||||
|
||||
|
||||
def _reputation_multiplier(node: "_NodeEntry", contracts: Any | None) -> float:
|
||||
"""ADR-0018 §6: veteran, good-standing nodes route more -- earnings scale
|
||||
with tenure/reputation. Maps reputation [0, 1] to a [0.5, 1.0] routing
|
||||
weight so a damaged-but-not-banned wallet loses traffic gradually rather
|
||||
than being cut off outright (banning is handled separately)."""
|
||||
if contracts is None or not node.wallet_address:
|
||||
return 1.0
|
||||
reputation = contracts.registry.get_wallet(node.wallet_address).reputation
|
||||
return 0.5 + 0.5 * reputation
|
||||
|
||||
|
||||
def _select_route(
|
||||
nodes: list[_NodeEntry],
|
||||
required_start: int,
|
||||
required_end: int,
|
||||
model: str | None = None,
|
||||
contracts: Any | None = None,
|
||||
) -> tuple[list[_NodeEntry], str]:
|
||||
"""Greedy interval-cover biased toward fast, lightly-loaded nodes.
|
||||
"""Greedy interval-cover biased toward fast, lightly-loaded, reputable nodes.
|
||||
|
||||
Among nodes that equally advance coverage, prefer the one with higher
|
||||
effective throughput: observed per-model tokens/sec / (queue_depth + 1),
|
||||
falling back to startup benchmark_tokens_per_sec until observations exist.
|
||||
falling back to startup benchmark_tokens_per_sec until observations exist,
|
||||
weighted by the node's reputation multiplier (see `_reputation_multiplier`).
|
||||
Tiebreak: higher shard_end (fewer hops).
|
||||
"""
|
||||
candidates = sorted(
|
||||
@@ -594,6 +608,9 @@ def _select_route(
|
||||
route: list[_NodeEntry] = []
|
||||
covered_up_to = required_start - 1
|
||||
|
||||
def _routing_score(node: "_NodeEntry") -> float:
|
||||
return _effective_throughput(node, model) * _reputation_multiplier(node, contracts)
|
||||
|
||||
while covered_up_to < required_end:
|
||||
best: _NodeEntry | None = None
|
||||
for node in candidates:
|
||||
@@ -602,7 +619,7 @@ def _select_route(
|
||||
best = node
|
||||
elif node.shard_end > best.shard_end:
|
||||
best = node
|
||||
elif node.shard_end == best.shard_end and _effective_throughput(node, model) > _effective_throughput(best, model):
|
||||
elif node.shard_end == best.shard_end and _routing_score(node) > _routing_score(best):
|
||||
best = node
|
||||
if best is None:
|
||||
missing = covered_up_to + 1
|
||||
@@ -1216,6 +1233,101 @@ def _usage_total_tokens(payload: dict) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def _estimate_text_tokens(value: Any) -> int | None:
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return 0
|
||||
return len(text.split())
|
||||
if isinstance(value, list):
|
||||
total = 0
|
||||
found = False
|
||||
for item in value:
|
||||
if isinstance(item, str):
|
||||
estimated = _estimate_text_tokens(item)
|
||||
elif isinstance(item, dict):
|
||||
estimated = _estimate_text_tokens(item.get("text"))
|
||||
else:
|
||||
estimated = None
|
||||
if estimated is not None:
|
||||
total += estimated
|
||||
found = True
|
||||
return total if found else None
|
||||
return None
|
||||
|
||||
|
||||
def _estimate_prompt_tokens(body: dict) -> int | None:
|
||||
messages = body.get("messages")
|
||||
if isinstance(messages, list):
|
||||
total = 0
|
||||
found = False
|
||||
for message in messages:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
estimated = _estimate_text_tokens(message.get("content"))
|
||||
if estimated is not None:
|
||||
total += estimated
|
||||
found = True
|
||||
return total if found else None
|
||||
prompt = body.get("prompt")
|
||||
return _estimate_text_tokens(prompt)
|
||||
|
||||
|
||||
def _requested_completion_token_limit(body: dict) -> int | None:
|
||||
for field in ("max_completion_tokens", "max_tokens"):
|
||||
value = body.get(field)
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return max(0, int(value))
|
||||
return None
|
||||
|
||||
|
||||
def _request_total_token_upper_bound(body: dict) -> int | None:
|
||||
completion_limit = _requested_completion_token_limit(body)
|
||||
if completion_limit is None:
|
||||
return None
|
||||
prompt_estimate = _estimate_prompt_tokens(body)
|
||||
return completion_limit + (prompt_estimate or 0)
|
||||
|
||||
|
||||
def _billable_non_stream_tokens(payload: dict, request_body: dict) -> int:
|
||||
reported = _usage_total_tokens(payload)
|
||||
if reported is None:
|
||||
return 0
|
||||
billable = max(0, reported)
|
||||
upper_bound = _request_total_token_upper_bound(request_body)
|
||||
if upper_bound is not None:
|
||||
billable = min(billable, upper_bound)
|
||||
return billable
|
||||
|
||||
|
||||
def _observed_stream_tokens(payload: dict) -> int:
|
||||
choices = payload.get("choices")
|
||||
if not isinstance(choices, list):
|
||||
return 0
|
||||
observed = 0
|
||||
for choice in choices:
|
||||
if not isinstance(choice, dict):
|
||||
continue
|
||||
delta = choice.get("delta")
|
||||
if isinstance(delta, dict):
|
||||
estimated = _estimate_text_tokens(delta.get("content"))
|
||||
if estimated:
|
||||
observed += estimated
|
||||
continue
|
||||
if choice:
|
||||
observed += 1
|
||||
return observed
|
||||
|
||||
|
||||
def _billable_stream_tokens(observed_tokens: int, reported_tokens: int | None) -> int:
|
||||
observed = max(0, observed_tokens)
|
||||
if reported_tokens is None:
|
||||
return observed
|
||||
return min(observed, max(0, reported_tokens))
|
||||
|
||||
|
||||
def _registration_ban_error(contracts: Any | None, wallet_address: str | None) -> str | None:
|
||||
if contracts is None or not wallet_address:
|
||||
return None
|
||||
@@ -1267,6 +1379,7 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
benchmark_results_path: str | None = None,
|
||||
validator_service_token: str | None = None,
|
||||
hive_secret: str | None = None,
|
||||
max_charge_per_request: float | None = None,
|
||||
) -> None:
|
||||
super().__init__(addr, handler)
|
||||
self.registry = registry
|
||||
@@ -1287,6 +1400,7 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
self.benchmark_lock = threading.Lock()
|
||||
self.validator_service_token = validator_service_token
|
||||
self.hive_secret = hive_secret
|
||||
self.max_charge_per_request = max_charge_per_request
|
||||
|
||||
|
||||
class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
@@ -1422,6 +1536,9 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
if self.path == "/v1/accounts/gossip":
|
||||
self._handle_accounts_gossip()
|
||||
return
|
||||
if self.path == "/v1/registry/gossip":
|
||||
self._handle_registry_gossip()
|
||||
return
|
||||
if self.path == "/v1/benchmark/hop-penalty":
|
||||
self._handle_benchmark_hop_penalty()
|
||||
return
|
||||
@@ -1752,6 +1869,30 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
"code": "insufficient_balance",
|
||||
}})
|
||||
return
|
||||
if server.max_charge_per_request is not None:
|
||||
token_limit = _requested_completion_token_limit(body)
|
||||
if token_limit is None:
|
||||
self._send_json(400, {"error": {
|
||||
"message": (
|
||||
"max_charge_per_request is enabled; include max_tokens "
|
||||
"or max_completion_tokens so this request can be capped"
|
||||
),
|
||||
"type": "invalid_request_error",
|
||||
"code": "missing_token_limit",
|
||||
}})
|
||||
return
|
||||
estimated_charge = server.billing.price_for(model) * token_limit / 1000.0
|
||||
if estimated_charge > server.max_charge_per_request:
|
||||
self._send_json(402, {"error": {
|
||||
"message": (
|
||||
f"request exceeds max_charge_per_request "
|
||||
f"({estimated_charge:.6f} USDT estimated > "
|
||||
f"{server.max_charge_per_request:.6f} USDT cap)"
|
||||
),
|
||||
"type": "insufficient_quota",
|
||||
"code": "spend_cap_exceeded",
|
||||
}})
|
||||
return
|
||||
|
||||
# US-030: optional pinned route — "route": [node_id, ...] uses those
|
||||
# nodes in order instead of auto-selection. Absent field: unchanged.
|
||||
@@ -1831,7 +1972,14 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
if pinned_nodes is not None:
|
||||
route_nodes = pinned_nodes
|
||||
else:
|
||||
route_nodes, _ = _select_route(all_nodes, rs, re, model=route_model)
|
||||
route_nodes, route_error = _select_route(all_nodes, rs, re, model=route_model, contracts=server.contracts)
|
||||
if route_error:
|
||||
self._send_json(503, {"error": {
|
||||
"message": route_error,
|
||||
"type": "service_unavailable",
|
||||
"code": "route_not_available",
|
||||
}})
|
||||
return
|
||||
# Compute start_layer for each hop: each node begins where the previous ended + 1.
|
||||
# This allows overlapping shard registrations without double-computation.
|
||||
covered_up_to = rs - 1
|
||||
@@ -1898,7 +2046,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
if int(relayed.get("status", 503)) < 400:
|
||||
body_text = relayed.get("body") or ""
|
||||
try:
|
||||
tokens = _usage_total_tokens(json.loads(body_text)) or 0
|
||||
tokens = _billable_non_stream_tokens(json.loads(body_text), body)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
tokens = 0
|
||||
self._record_observed_throughput(model, route_model, tokens, elapsed, route_nodes)
|
||||
@@ -1950,8 +2098,8 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
stream_tokens: int | None = None
|
||||
chunk_count = 0
|
||||
reported_stream_tokens: int | None = None
|
||||
observed_stream_tokens = 0
|
||||
try:
|
||||
while True:
|
||||
line = upstream.readline()
|
||||
@@ -1962,21 +2110,22 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
if line.startswith(b"data:"):
|
||||
payload = line[5:].strip()
|
||||
if payload and payload != b"[DONE]":
|
||||
chunk_count += 1
|
||||
if b'"usage"' in payload:
|
||||
try:
|
||||
found = _usage_total_tokens(json.loads(payload))
|
||||
if found:
|
||||
stream_tokens = found
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
try:
|
||||
chunk_payload = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
observed_stream_tokens += 1
|
||||
continue
|
||||
observed_stream_tokens += _observed_stream_tokens(chunk_payload)
|
||||
found = _usage_total_tokens(chunk_payload)
|
||||
if found is not None:
|
||||
reported_stream_tokens = found
|
||||
except BrokenPipeError:
|
||||
pass
|
||||
elapsed = time.monotonic() - started
|
||||
# Bill even on client disconnect — the nodes did the work.
|
||||
# Chunk count approximates generated tokens when the stream
|
||||
# carries no usage record.
|
||||
observed_tokens = stream_tokens if stream_tokens is not None else chunk_count
|
||||
# Observed stream chunks are authoritative for the upper bound;
|
||||
# upstream usage may only lower that count.
|
||||
observed_tokens = _billable_stream_tokens(observed_stream_tokens, reported_stream_tokens)
|
||||
self._record_observed_throughput(model, route_model, observed_tokens, elapsed, route_nodes)
|
||||
self._bill_completed(
|
||||
api_key, model,
|
||||
@@ -1991,6 +2140,12 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
f"[tracker] proxy complete {request_id}: {target_url} bytes={len(resp_body)}",
|
||||
flush=True,
|
||||
)
|
||||
try:
|
||||
tokens = _billable_non_stream_tokens(json.loads(resp_body), body)
|
||||
except json.JSONDecodeError:
|
||||
tokens = 0
|
||||
self._record_observed_throughput(model, route_model, tokens, elapsed, route_nodes)
|
||||
self._bill_completed(api_key, model, tokens, node_work)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(resp_body)))
|
||||
@@ -1999,12 +2154,6 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
self.wfile.write(resp_body)
|
||||
except BrokenPipeError:
|
||||
pass
|
||||
try:
|
||||
tokens = _usage_total_tokens(json.loads(resp_body)) or 0
|
||||
except json.JSONDecodeError:
|
||||
tokens = 0
|
||||
self._record_observed_throughput(model, route_model, tokens, elapsed, route_nodes)
|
||||
self._bill_completed(api_key, model, tokens, node_work)
|
||||
|
||||
def _record_observed_throughput(
|
||||
self,
|
||||
@@ -2468,6 +2617,11 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
"strike_count": state.strike_count,
|
||||
"banned": state.banned,
|
||||
"completed_jobs": state.completed_job_count,
|
||||
"reputation": state.reputation,
|
||||
"last_audit_ts": state.last_audit_ts,
|
||||
"probationary_jobs_remaining": (
|
||||
server.contracts.registry.probationary_jobs_remaining(wallet)
|
||||
),
|
||||
}
|
||||
for wallet, state in wallets.items()
|
||||
}})
|
||||
@@ -2568,7 +2722,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
api_key = accounts.create_api_key(account["account_id"])
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
if server.billing is not None:
|
||||
server.billing.ensure_client(api_key) # grant Caller Credit up front
|
||||
server.billing.ensure_client(api_key)
|
||||
print(
|
||||
f"[tracker] account registered: {account.get('email') or account.get('wallet')} "
|
||||
f"role={account['role']}", flush=True,
|
||||
@@ -2691,20 +2845,39 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
applied = server.accounts.apply_events([e for e in events if isinstance(e, dict)])
|
||||
self._send_json(200, {"applied": applied})
|
||||
|
||||
def _handle_registry_gossip(self):
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
body = self._read_hive_authenticated_body()
|
||||
if body is None:
|
||||
return
|
||||
registry_log = getattr(server.contracts, "registry_log", None)
|
||||
if registry_log is None:
|
||||
self._send_json(200, {"applied": 0})
|
||||
return
|
||||
events = body.get("events")
|
||||
if not isinstance(events, list):
|
||||
self._send_json(400, {"error": "events must be a list"})
|
||||
return
|
||||
applied = registry_log.apply_events([e for e in events if isinstance(e, dict)])
|
||||
self._send_json(200, {"applied": applied})
|
||||
|
||||
def _handle_wallet_register(self):
|
||||
"""Bind the caller's client wallet pubkey to their API key (US-032).
|
||||
"""Bind a client wallet pubkey to an API key (US-032, C6).
|
||||
|
||||
Deposits from that wallet into the treasury are then credited to the
|
||||
API key's ledger balance by the deposit watcher.
|
||||
|
||||
Requires a signature proving ownership of the wallet's private key —
|
||||
the caller signs ``binding_message(api_key, wallet)`` with the
|
||||
wallet's ed25519 key. An admin session may force-rebind a wallet
|
||||
already bound to a different API key (documented override; no
|
||||
signed-release flow is implemented).
|
||||
"""
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
api_key = _api_key_from_headers(self.headers)
|
||||
if not api_key:
|
||||
self._send_json(401, {"error": "Authorization header required"})
|
||||
return
|
||||
if server.billing is None:
|
||||
self._send_json(404, {"error": "billing is not enabled on this tracker"})
|
||||
return
|
||||
role, _account = self._resolve_identity()
|
||||
body = self._read_json_body()
|
||||
if body is None:
|
||||
return
|
||||
@@ -2712,7 +2885,30 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
if not wallet or not isinstance(wallet, str):
|
||||
self._send_json(400, {"error": "wallet is required"})
|
||||
return
|
||||
server.billing.bind_wallet(api_key, wallet)
|
||||
|
||||
if role == "admin":
|
||||
api_key = body.get("api_key")
|
||||
if not api_key or not isinstance(api_key, str):
|
||||
self._send_json(400, {"error": "api_key is required for admin override"})
|
||||
return
|
||||
event = server.billing.bind_wallet(api_key, wallet, admin_override=True)
|
||||
else:
|
||||
api_key = _api_key_from_headers(self.headers)
|
||||
if not api_key:
|
||||
self._send_json(401, {"error": "Authorization header required"})
|
||||
return
|
||||
signature = body.get("signature")
|
||||
if not signature or not isinstance(signature, str):
|
||||
self._send_json(400, {"error": "signature is required to prove wallet ownership"})
|
||||
return
|
||||
if not verify_wallet_signature(wallet, binding_message(api_key, wallet), signature):
|
||||
self._send_json(401, {"error": "invalid wallet signature"})
|
||||
return
|
||||
event = server.billing.bind_wallet(api_key, wallet)
|
||||
|
||||
if event.get("rejected"):
|
||||
self._send_json(409, {"error": "wallet already bound to a different API key"})
|
||||
return
|
||||
print(f"[tracker] wallet bound: {wallet} -> api_key …{api_key[-6:]}", flush=True)
|
||||
self._send_json(200, {"wallet": wallet, "bound": True})
|
||||
|
||||
@@ -3130,7 +3326,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
if not node.wallet_address or not server.contracts.registry.get_wallet(node.wallet_address).banned
|
||||
]
|
||||
|
||||
route, error = _select_route(alive, required_start, required_end)
|
||||
route, error = _select_route(alive, required_start, required_end, contracts=server.contracts)
|
||||
if error:
|
||||
self._send_json(503, {"error": error})
|
||||
return
|
||||
@@ -3204,7 +3400,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
routes = []
|
||||
remaining = list(candidates)
|
||||
for _ in range(redundancy):
|
||||
route, error = _select_route(remaining, required_start, required_end)
|
||||
route, error = _select_route(remaining, required_start, required_end, contracts=server.contracts)
|
||||
if error:
|
||||
self._send_json(503, {"error": error})
|
||||
return
|
||||
@@ -3267,6 +3463,7 @@ class TrackerServer:
|
||||
settlement_check_interval: float | None = None,
|
||||
validator_service_token: str | None = None,
|
||||
hive_secret: str | None = None,
|
||||
max_charge_per_request: float | None = None,
|
||||
) -> None:
|
||||
self._host = host
|
||||
self._requested_port = port
|
||||
@@ -3312,6 +3509,7 @@ class TrackerServer:
|
||||
accounts = AccountStore(db_path=accounts_path)
|
||||
self._accounts: AccountStore | None = accounts
|
||||
self._accounts_gossip_cursor = 0
|
||||
self._registry_gossip_cursor = 0
|
||||
self._benchmark_results_path = benchmark_results_path
|
||||
self._treasury = treasury
|
||||
self._deposit_poll_interval = deposit_poll_interval
|
||||
@@ -3325,6 +3523,9 @@ class TrackerServer:
|
||||
)
|
||||
self._settlement_stop = threading.Event()
|
||||
self._settlement_thread: threading.Thread | None = None
|
||||
if max_charge_per_request is not None and max_charge_per_request <= 0.0:
|
||||
raise ValueError("max_charge_per_request must be positive")
|
||||
self._max_charge_per_request = max_charge_per_request
|
||||
self._validator_service_token = (
|
||||
validator_service_token
|
||||
if validator_service_token is not None
|
||||
@@ -3363,6 +3564,7 @@ class TrackerServer:
|
||||
benchmark_results_path=self._benchmark_results_path,
|
||||
validator_service_token=self._validator_service_token,
|
||||
hive_secret=self._hive_secret,
|
||||
max_charge_per_request=self._max_charge_per_request,
|
||||
)
|
||||
self.port = self._server.server_address[1]
|
||||
|
||||
@@ -3432,9 +3634,24 @@ class TrackerServer:
|
||||
if not due:
|
||||
continue
|
||||
settlement_id = uuid.uuid4().hex
|
||||
registry = (
|
||||
self._server.contracts.registry
|
||||
if self._server is not None and self._server.contracts is not None
|
||||
else None
|
||||
)
|
||||
settled: list[tuple[str, float]] = []
|
||||
for wallet, amount in due:
|
||||
billing.settle_node_payout(wallet, amount, reference=settlement_id)
|
||||
self._send_settlement(settlement_id, due)
|
||||
# ADR-0015: a fraud forfeiture landing between the payables()
|
||||
# snapshot above and this debit must never be paid out on top
|
||||
# of — recheck the ban and let settle_node_payout clamp to
|
||||
# whatever is still actually pending.
|
||||
if registry is not None and registry.get_wallet(wallet).banned:
|
||||
continue
|
||||
event = billing.settle_node_payout(wallet, amount, reference=settlement_id)
|
||||
if event["amount"] > 0:
|
||||
settled.append((wallet, event["amount"]))
|
||||
if settled:
|
||||
self._send_settlement(settlement_id, settled)
|
||||
|
||||
def _send_settlement(self, settlement_id: str, payouts: list) -> None:
|
||||
billing = self._billing
|
||||
@@ -3557,6 +3774,9 @@ class TrackerServer:
|
||||
self._billing.save_to_db()
|
||||
if self._accounts is not None:
|
||||
self._accounts.save_to_db()
|
||||
registry_log = getattr(self._contracts, "registry_log", None)
|
||||
if registry_log is not None:
|
||||
registry_log.save_to_db()
|
||||
if self._cluster_peers and not self._hive_secret:
|
||||
print(
|
||||
"[tracker] WARNING: cluster peers configured without --hive-secret — "
|
||||
@@ -3583,6 +3803,13 @@ class TrackerServer:
|
||||
body = json.dumps({"events": events}).encode()
|
||||
if self._push_to_peers("/v1/accounts/gossip", body):
|
||||
self._accounts_gossip_cursor = cursor
|
||||
registry_log = getattr(self._contracts, "registry_log", None)
|
||||
if registry_log is not None and self._cluster_peers:
|
||||
events, cursor = registry_log.events_since(self._registry_gossip_cursor)
|
||||
if events:
|
||||
body = json.dumps({"events": events}).encode()
|
||||
if self._push_to_peers("/v1/registry/gossip", body):
|
||||
self._registry_gossip_cursor = cursor
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._raft is not None:
|
||||
@@ -3601,6 +3828,9 @@ class TrackerServer:
|
||||
self._billing.save_to_db()
|
||||
if self._accounts is not None:
|
||||
self._accounts.save_to_db()
|
||||
registry_log = getattr(self._contracts, "registry_log", None)
|
||||
if registry_log is not None:
|
||||
registry_log.save_to_db()
|
||||
self._server.shutdown()
|
||||
self._server.server_close()
|
||||
if self._thread is not None:
|
||||
|
||||
49
packages/tracker/meshnet_tracker/wallet_proof.py
Normal file
49
packages/tracker/meshnet_tracker/wallet_proof.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Ed25519 proof-of-ownership for client wallet binding (ADR-0017 §5, issue C6).
|
||||
|
||||
Wallet addresses are base58-encoded Solana ed25519 public keys, the same
|
||||
format used by ``packages/node/meshnet_node/wallet.py``. Binding a wallet to
|
||||
an API key requires a signature over a message that embeds the api_key, so a
|
||||
captured (wallet, signature) pair cannot be replayed to bind a different key.
|
||||
|
||||
No Solana RPC dependency is needed for verification, so this stays on plain
|
||||
``cryptography`` rather than pulling in ``solders`` on the tracker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
|
||||
_B58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
||||
_B58_INDEX = {char: index for index, char in enumerate(_B58_ALPHABET)}
|
||||
|
||||
|
||||
def b58decode(value: str) -> bytes:
|
||||
"""Decode a base58 string (Solana/Bitcoin alphabet) to bytes."""
|
||||
n = 0
|
||||
for char in value:
|
||||
digit = _B58_INDEX.get(char)
|
||||
if digit is None:
|
||||
raise ValueError(f"invalid base58 character: {char!r}")
|
||||
n = n * 58 + digit
|
||||
body = n.to_bytes((n.bit_length() + 7) // 8, "big") if n else b""
|
||||
leading_zeros = len(value) - len(value.lstrip("1"))
|
||||
return b"\x00" * leading_zeros + body
|
||||
|
||||
|
||||
def binding_message(api_key: str, wallet: str) -> bytes:
|
||||
"""Message a wallet owner must sign to bind ``wallet`` to ``api_key``."""
|
||||
return f"meshnet-wallet-bind:{api_key}:{wallet}".encode()
|
||||
|
||||
|
||||
def verify_wallet_signature(wallet: str, message: bytes, signature_hex: str) -> bool:
|
||||
"""True if ``signature_hex`` is a valid ed25519 signature by ``wallet`` over ``message``."""
|
||||
try:
|
||||
pubkey_bytes = b58decode(wallet)
|
||||
if len(pubkey_bytes) != 32:
|
||||
return False
|
||||
signature = bytes.fromhex(signature_hex)
|
||||
Ed25519PublicKey.from_public_bytes(pubkey_bytes).verify(signature, message)
|
||||
return True
|
||||
except (ValueError, InvalidSignature):
|
||||
return False
|
||||
@@ -9,6 +9,7 @@ description = "Distributed Inference Network node registry and route selection"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
dependencies = [
|
||||
"cryptography>=41",
|
||||
"websockets>=13",
|
||||
]
|
||||
|
||||
|
||||
@@ -2,28 +2,58 @@
|
||||
|
||||
Optimistic fraud detection (ADR-0003, penalty amended by ADR-0015): the
|
||||
validator re-runs a random ~5% sample of completed inference requests against
|
||||
a trusted reference node and, on divergence, submits a slash proof and
|
||||
forfeits the node's pending balance.
|
||||
a trusted reference node. Audit-capable events are checked by teacher-forcing
|
||||
the claimed token sequence through the reference node and verifying the
|
||||
claimed TOPLOC activation proof. Legacy events without TOPLOC metadata still
|
||||
fall back to text comparison until node-side proof capture lands.
|
||||
|
||||
## Why the penalty deters cheating
|
||||
|
||||
There is no upfront stake. Settlement is periodic (US-033), so a node always
|
||||
has an unpaid **pending balance** — that balance *is* the collateral.
|
||||
|
||||
At a sampling rate `p`, a cheater is caught on average once every `1/p`
|
||||
fraudulent jobs, so cheating is unprofitable when:
|
||||
At a sampling rate `p`, a cheater who gains `G` per fraudulent job and loses
|
||||
`L` when caught has expected value:
|
||||
|
||||
```
|
||||
penalty > per_job_gain / p # p = 0.05 → penalty > 20 × per_job_gain
|
||||
(1 - p) * G - p * L < 0
|
||||
L > ((1 - p) / p) * G # p = 0.05 -> L > 19 x G
|
||||
```
|
||||
|
||||
With the production settlement period of 24h, the pending balance at any
|
||||
moment approximates a full day's earnings — hundreds to thousands of jobs —
|
||||
which is far above the 20× bar. Each catch also records a strike; three
|
||||
which is far above the 19× bar. Each catch also records a strike; three
|
||||
strikes ban the wallet (registration rejected, excluded from routes, unpaid
|
||||
pending never settled), and the probationary period (first N jobs unpaid)
|
||||
makes re-entry with a fresh wallet costly.
|
||||
|
||||
## TOPLOC audit contract
|
||||
|
||||
The validator expects audit-capable events to carry:
|
||||
|
||||
- `claimed_token_ids`: the final token sequence claimed by the prover.
|
||||
- `toploc_proof`: compact TOPLOC proof data built from prover activations.
|
||||
|
||||
On audit the validator calls the reference node's `POST /v1/audit/toploc`
|
||||
endpoint with the original messages plus `claimed_token_ids`. The reference
|
||||
node must run a teacher-forced prefill over exactly that token sequence and
|
||||
return the activations for TOPLOC verification. It must not free-generate a
|
||||
second answer for audit.
|
||||
|
||||
Canonical audit parameters for the current alpha preset are:
|
||||
|
||||
```
|
||||
dtype = "bfloat16"
|
||||
quantization = "bfloat16"
|
||||
decode_batching_size = 32
|
||||
topk = 8
|
||||
skip_prefill = true
|
||||
encoding = "base64"
|
||||
```
|
||||
|
||||
Production audit thresholds remain gated on the honest-noise calibration
|
||||
corpus in issue 21.
|
||||
|
||||
Two operational notes:
|
||||
|
||||
- Shortening the settlement period shrinks the collateral. Period changes
|
||||
@@ -44,3 +74,27 @@ ValidatorProcess(
|
||||
|
||||
Remote validators can instead call the tracker's privileged
|
||||
`POST /v1/billing/forfeit` endpoint (non-empty Authorization header).
|
||||
|
||||
## Reputation-weighted audit rate (ADR-0018 §1, §6-7)
|
||||
|
||||
`sample_rate` is a flat coin flip: every wallet audited at the same rate.
|
||||
Pass `audit_sampler=AdaptiveAuditSampler(...)` instead to make the audit
|
||||
probability a function of each wallet's tenure and reputation — newcomers
|
||||
and low-reputation wallets sampled at 20–30%, veterans in good standing
|
||||
floor at ≥2% — while a running budget balance keeps the fleet-wide realized
|
||||
rate anchored to `AuditRateConfig.target_rate` (default 5%) regardless of
|
||||
the wallet mix. Passive tripwires (`detect_output_tripwire`) bump only that
|
||||
one request's odds; they never strike, ban, or affect other wallets' rates.
|
||||
|
||||
```python
|
||||
from meshnet_validator import AdaptiveAuditSampler, detect_output_tripwire
|
||||
|
||||
ValidatorProcess(
|
||||
contracts=contracts,
|
||||
reference_node_url="http://...",
|
||||
audit_sampler=AdaptiveAuditSampler(random_seed=42),
|
||||
)
|
||||
```
|
||||
|
||||
When `audit_sampler` is set, `sample_rate` is ignored — the sampler decides
|
||||
per event, keyed on whichever route wallet has the lowest reputation.
|
||||
|
||||
@@ -8,6 +8,10 @@ import time
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
from .audit import ToplocAuditConfig, ToplocProofClaim, verify_activation_proofs
|
||||
from .sampling import AdaptiveAuditSampler, AuditRateConfig
|
||||
from .tripwire import detect_output_tripwire
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
|
||||
@@ -27,6 +31,9 @@ class ValidatorProcess:
|
||||
webhook_url: str | None = None,
|
||||
interval_seconds: float = 1.0,
|
||||
billing: Any | None = None,
|
||||
toploc_config: ToplocAuditConfig | None = None,
|
||||
toploc_backend: Any | None = None,
|
||||
audit_sampler: AdaptiveAuditSampler | None = None,
|
||||
) -> None:
|
||||
if not 0.0 <= sample_rate <= 1.0:
|
||||
raise ValueError("sample_rate must be between 0 and 1")
|
||||
@@ -48,6 +55,9 @@ class ValidatorProcess:
|
||||
self._strike_threshold = strike_threshold
|
||||
self._webhook_url = webhook_url
|
||||
self._interval_seconds = interval_seconds
|
||||
self._toploc_config = toploc_config or ToplocAuditConfig()
|
||||
self._toploc_backend = toploc_backend
|
||||
self._audit_sampler = audit_sampler
|
||||
self._random = random.Random(random_seed)
|
||||
self._last_event_index = -1
|
||||
self._running = False
|
||||
@@ -62,15 +72,47 @@ class ValidatorProcess:
|
||||
)
|
||||
for event in events:
|
||||
self._last_event_index = max(self._last_event_index, event.index)
|
||||
if self._random.random() >= self._sample_rate:
|
||||
if not self._should_sample(event):
|
||||
continue
|
||||
self.sampled_count += 1
|
||||
reference_output = self._run_reference(event.messages)
|
||||
if _outputs_match(event.observed_output, reference_output, self._tolerance):
|
||||
audit_result = self._validate_event(event)
|
||||
if audit_result.ok:
|
||||
self._record_clean_audit(event)
|
||||
continue
|
||||
receipts.extend(self._slash_route(event.route_nodes, event.observed_output, reference_output))
|
||||
receipts.extend(self._slash_node(
|
||||
audit_result.culprit_node,
|
||||
event.observed_output,
|
||||
audit_result.reference_output,
|
||||
reason=audit_result.reason,
|
||||
))
|
||||
return receipts
|
||||
|
||||
def _should_sample(self, event: Any) -> bool:
|
||||
"""ADR-0018 §1/§6-7: flat sample_rate stays the default; when an
|
||||
AdaptiveAuditSampler is configured, the decision is reputation- and
|
||||
tenure-weighted and budget-balanced against the fleet-wide target
|
||||
instead of a uniform coin flip."""
|
||||
if self._audit_sampler is None:
|
||||
return self._random.random() < self._sample_rate
|
||||
|
||||
tripwire = detect_output_tripwire(_event_value(event, "observed_output") or "")
|
||||
wallets = _route_wallets(event)
|
||||
if not wallets:
|
||||
return self._audit_sampler.should_audit(
|
||||
completed_job_count=0, reputation=1.0, tripwire=tripwire,
|
||||
)
|
||||
# A route is only as trustworthy as its least-trusted hop -- audit
|
||||
# against whichever wallet on the route looks riskiest.
|
||||
riskiest = min(
|
||||
(self._contracts.registry.get_wallet(wallet) for wallet in wallets),
|
||||
key=lambda wallet: wallet.reputation,
|
||||
)
|
||||
return self._audit_sampler.should_audit(
|
||||
completed_job_count=riskiest.completed_job_count,
|
||||
reputation=riskiest.reputation,
|
||||
tripwire=tripwire,
|
||||
)
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
raise RuntimeError("ValidatorProcess is already running")
|
||||
@@ -99,14 +141,141 @@ class ValidatorProcess:
|
||||
raise ValueError("reference node response did not contain text")
|
||||
return text
|
||||
|
||||
def _slash_route(
|
||||
def _validate_event(self, event: Any) -> "_AuditResult":
|
||||
hop_commitments = _hop_commitments_from_event(event)
|
||||
if hop_commitments is not None and self._commitment_expired(event):
|
||||
# ADR-0018 §3: the on-demand retention window has passed — nodes
|
||||
# are no longer expected to hold the boundary activations needed
|
||||
# to verify this commitment, so fall back to the text-only path.
|
||||
hop_commitments = None
|
||||
|
||||
if hop_commitments is None:
|
||||
reference_output = self._run_reference(event.messages)
|
||||
ok = _outputs_match(event.observed_output, reference_output, self._tolerance)
|
||||
return _AuditResult(
|
||||
ok=ok,
|
||||
reference_output=reference_output,
|
||||
reason="reference output diverged",
|
||||
# Text comparison has no per-hop signal; the last hop is the
|
||||
# best-effort guess (text-only fallback), never used when
|
||||
# hop-boundary commitments make real bisection possible.
|
||||
culprit_node=None if ok else _final_text_node(event.route_nodes),
|
||||
)
|
||||
|
||||
if len(hop_commitments) == 1:
|
||||
# Single-commitment route (AH-006 whole-route format, or a
|
||||
# genuinely one-hop pipeline): reuse the original teacher-forced
|
||||
# call so existing single-hop reference integrations keep working.
|
||||
only = hop_commitments[0]
|
||||
reference_activations_by_hop = [self._run_teacher_forced_prefill(
|
||||
model=event.model,
|
||||
messages=event.messages,
|
||||
claimed_token_ids=only.token_ids,
|
||||
claim=only.claim,
|
||||
)]
|
||||
else:
|
||||
reference_activations_by_hop = self._run_teacher_forced_prefill_hops(
|
||||
model=event.model,
|
||||
messages=event.messages,
|
||||
hop_commitments=hop_commitments,
|
||||
)
|
||||
culprit_index = _first_divergent_hop(
|
||||
hop_commitments,
|
||||
reference_activations_by_hop,
|
||||
config=self._toploc_config,
|
||||
backend=self._toploc_backend,
|
||||
)
|
||||
ok = culprit_index is None
|
||||
return _AuditResult(
|
||||
ok=ok,
|
||||
reference_output=(
|
||||
"TOPLOC activation proof accepted"
|
||||
if ok
|
||||
else f"TOPLOC activation proof mismatch at hop {culprit_index}"
|
||||
),
|
||||
reason="TOPLOC activation proof mismatch",
|
||||
culprit_node=None if ok else hop_commitments[culprit_index].node,
|
||||
)
|
||||
|
||||
def _commitment_expired(self, event: Any) -> bool:
|
||||
ts = _event_value(event, "ts")
|
||||
if ts is None:
|
||||
return False
|
||||
return (time.time() - float(ts)) > self._toploc_config.commitment_ttl_seconds
|
||||
|
||||
def _run_teacher_forced_prefill(
|
||||
self,
|
||||
route_nodes: list[dict],
|
||||
*,
|
||||
model: str,
|
||||
messages: list[dict],
|
||||
claimed_token_ids: list[int],
|
||||
claim: ToplocProofClaim,
|
||||
) -> list[Any]:
|
||||
response = _post_json(
|
||||
f"{self._reference_node_url}/v1/audit/toploc",
|
||||
{
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"claimed_token_ids": claimed_token_ids,
|
||||
"dtype": claim.dtype,
|
||||
"quantization": claim.quantization,
|
||||
"decode_batching_size": claim.decode_batching_size,
|
||||
"topk": claim.topk,
|
||||
"skip_prefill": claim.skip_prefill,
|
||||
},
|
||||
)
|
||||
activations = response.get("activations")
|
||||
if not isinstance(activations, list):
|
||||
raise ValueError("reference node audit response did not contain activations")
|
||||
return activations
|
||||
|
||||
def _run_teacher_forced_prefill_hops(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
messages: list[dict],
|
||||
hop_commitments: list["_HopCommitment"],
|
||||
) -> list[list[Any]]:
|
||||
"""Teacher-force the claimed tokens once and collect reference
|
||||
activations at every hop's boundary layer (ADR-0018 §4 / research §1.2:
|
||||
one referee forward pass, compared at each cut-point)."""
|
||||
reference_claim = hop_commitments[0].claim
|
||||
response = _post_json(
|
||||
f"{self._reference_node_url}/v1/audit/toploc",
|
||||
{
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"claimed_token_ids": hop_commitments[-1].token_ids,
|
||||
"hop_boundaries": [hop.shard_end for hop in hop_commitments],
|
||||
"dtype": reference_claim.dtype,
|
||||
"quantization": reference_claim.quantization,
|
||||
"decode_batching_size": reference_claim.decode_batching_size,
|
||||
"topk": reference_claim.topk,
|
||||
"skip_prefill": reference_claim.skip_prefill,
|
||||
},
|
||||
)
|
||||
activations_by_hop = response.get("activations_by_hop")
|
||||
if not isinstance(activations_by_hop, list) or len(activations_by_hop) != len(hop_commitments):
|
||||
raise ValueError("reference node audit response did not contain per-hop activations")
|
||||
return activations_by_hop
|
||||
|
||||
def _record_clean_audit(self, event: Any) -> None:
|
||||
"""ADR-0018 §6: reputation derives only from tracker-verified audit
|
||||
outcomes — a clean audit credits every node on the verified route."""
|
||||
for wallet_address in _route_wallets(event):
|
||||
if self._contracts.registry.get_wallet(wallet_address).banned:
|
||||
continue
|
||||
self._contracts.registry.record_audit_outcome(wallet_address, passed=True)
|
||||
|
||||
def _slash_node(
|
||||
self,
|
||||
node: dict | None,
|
||||
observed_output: str,
|
||||
reference_output: str,
|
||||
*,
|
||||
reason: str = "reference output diverged",
|
||||
) -> list[Any]:
|
||||
receipts: list[Any] = []
|
||||
node = _final_text_node(route_nodes)
|
||||
wallet_address = node.get("wallet_address") if node else None
|
||||
if not wallet_address:
|
||||
return receipts
|
||||
@@ -117,11 +286,14 @@ class ValidatorProcess:
|
||||
slash_amount=self._slash_amount,
|
||||
strike_threshold=self._strike_threshold,
|
||||
reason=(
|
||||
"reference output diverged "
|
||||
f"{reason} "
|
||||
f"(observed={observed_output!r}, reference={reference_output!r})"
|
||||
),
|
||||
webhook_url=self._webhook_url,
|
||||
))
|
||||
# ADR-0018 §6: reputation loss is separate from the strike/ban that
|
||||
# submit_slash_proof already recorded above — never double-strike.
|
||||
self._contracts.registry.record_audit_outcome(wallet_address, passed=False)
|
||||
# ADR-0015: the pending balance is the collateral — forfeit it in the
|
||||
# same validation cycle as the strike.
|
||||
if self._billing is not None:
|
||||
@@ -134,12 +306,149 @@ class ValidatorProcess:
|
||||
return receipts
|
||||
|
||||
|
||||
def _route_wallets(event: Any) -> list[str]:
|
||||
"""Unique wallet addresses across a route, in hop order."""
|
||||
route_nodes = _event_value(event, "route_nodes") or []
|
||||
seen: set[str] = set()
|
||||
wallets: list[str] = []
|
||||
for node in route_nodes:
|
||||
wallet_address = node.get("wallet_address") if isinstance(node, dict) else None
|
||||
if wallet_address and wallet_address not in seen:
|
||||
seen.add(wallet_address)
|
||||
wallets.append(wallet_address)
|
||||
return wallets
|
||||
|
||||
|
||||
def _final_text_node(route_nodes: list[dict]) -> dict | None:
|
||||
"""Text-only fallback blame: when the audit has no per-hop fingerprints
|
||||
to bisect (free-running text comparison only), guess the last hop.
|
||||
Never used once hop-boundary commitments make real bisection possible."""
|
||||
if not route_nodes:
|
||||
return None
|
||||
return max(route_nodes, key=lambda node: int(node.get("shard_end", 0)))
|
||||
|
||||
|
||||
class _AuditResult:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
ok: bool,
|
||||
reference_output: str,
|
||||
reason: str,
|
||||
culprit_node: dict | None = None,
|
||||
) -> None:
|
||||
self.ok = ok
|
||||
self.reference_output = reference_output
|
||||
self.reason = reason
|
||||
self.culprit_node = culprit_node
|
||||
|
||||
|
||||
class _HopCommitment:
|
||||
"""One hop's on-demand TOPLOC commitment plus the route node it blames."""
|
||||
|
||||
def __init__(self, node: dict | None, claim: ToplocProofClaim, token_ids: list[int]) -> None:
|
||||
self.node = node
|
||||
self.claim = claim
|
||||
self.token_ids = token_ids
|
||||
self.shard_end = int(node.get("shard_end", 0)) if node else None
|
||||
|
||||
|
||||
def _hop_commitments_from_event(event: Any) -> list[_HopCommitment] | None:
|
||||
"""Per-hop bisection commitments (ADR-0018 §3/§4): each route node reports
|
||||
its own output-boundary fingerprint. Falls back to the AH-006 whole-route
|
||||
commitment format (one fingerprint, no bisection) when hops don't carry
|
||||
individual commitments."""
|
||||
route_nodes = _event_value(event, "route_nodes") or []
|
||||
per_hop_nodes = [
|
||||
node for node in route_nodes
|
||||
if isinstance(node, dict) and _mapping_value(node, "toploc_proof") is not None
|
||||
]
|
||||
if per_hop_nodes and len(per_hop_nodes) == len(route_nodes):
|
||||
ordered = sorted(route_nodes, key=lambda node: int(node.get("shard_start", 0)))
|
||||
default_token_ids = _event_value(event, "claimed_token_ids")
|
||||
commitments = []
|
||||
for node in ordered:
|
||||
token_ids = node.get("claimed_token_ids", default_token_ids)
|
||||
if not isinstance(token_ids, list) or not all(isinstance(t, int) for t in token_ids):
|
||||
raise ValueError("TOPLOC hop commitments must include claimed_token_ids")
|
||||
commitments.append(_HopCommitment(
|
||||
node,
|
||||
ToplocProofClaim.from_mapping(node["toploc_proof"]),
|
||||
token_ids,
|
||||
))
|
||||
return commitments
|
||||
|
||||
whole_route = _toploc_audit_from_event(event)
|
||||
if whole_route is None:
|
||||
return None
|
||||
token_ids, claim = whole_route
|
||||
return [_HopCommitment(route_nodes[-1] if route_nodes else None, claim, token_ids)]
|
||||
|
||||
|
||||
def _first_divergent_hop(
|
||||
hop_commitments: list[_HopCommitment],
|
||||
reference_activations_by_hop: list[Any],
|
||||
*,
|
||||
config: ToplocAuditConfig,
|
||||
backend: Any | None,
|
||||
) -> int | None:
|
||||
"""First hop whose committed output fingerprint diverges from the
|
||||
referee's independently-computed reference activations at that same
|
||||
cut-point (research §1.2: no interactive game needed at hop granularity —
|
||||
the referee checks every cut-point in one replay)."""
|
||||
for index, commitment in enumerate(hop_commitments):
|
||||
ok = verify_activation_proofs(
|
||||
reference_activations_by_hop[index],
|
||||
commitment.claim,
|
||||
config=config,
|
||||
backend=backend,
|
||||
)
|
||||
if not ok:
|
||||
return index
|
||||
return None
|
||||
|
||||
|
||||
def _toploc_audit_from_event(event: Any) -> tuple[list[int], ToplocProofClaim] | None:
|
||||
audit = _event_mapping(event, "audit")
|
||||
claim_data = (
|
||||
_event_mapping(event, "toploc_proof")
|
||||
or _event_mapping(event, "activation_proof")
|
||||
or _mapping_value(audit, "toploc_proof")
|
||||
or _mapping_value(audit, "activation_proof")
|
||||
or _mapping_value(audit, "toploc")
|
||||
)
|
||||
if claim_data is None:
|
||||
return None
|
||||
token_ids = (
|
||||
_event_value(event, "claimed_token_ids")
|
||||
or _event_value(event, "output_token_ids")
|
||||
or _mapping_value(audit, "claimed_token_ids")
|
||||
or _mapping_value(audit, "output_token_ids")
|
||||
)
|
||||
if not isinstance(token_ids, list) or not all(isinstance(token, int) for token in token_ids):
|
||||
raise ValueError("TOPLOC audit events must include claimed_token_ids")
|
||||
return token_ids, ToplocProofClaim.from_mapping(claim_data)
|
||||
|
||||
|
||||
def _event_value(event: Any, name: str) -> Any:
|
||||
if hasattr(event, name):
|
||||
return getattr(event, name)
|
||||
if isinstance(event, dict):
|
||||
return event.get(name)
|
||||
return None
|
||||
|
||||
|
||||
def _event_mapping(event: Any, name: str) -> dict[str, Any] | None:
|
||||
value = _event_value(event, name)
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _mapping_value(mapping: dict[str, Any] | None, name: str) -> Any:
|
||||
if mapping is None:
|
||||
return None
|
||||
return mapping.get(name)
|
||||
|
||||
|
||||
def _outputs_match(observed: str, reference: str, tolerance: float) -> bool:
|
||||
observed_float = _parse_float(observed)
|
||||
reference_float = _parse_float(reference)
|
||||
@@ -167,4 +476,11 @@ def _post_json(url: str, payload: dict, timeout: float = 5.0) -> dict:
|
||||
return json.loads(response.read())
|
||||
|
||||
|
||||
__all__ = ["ValidatorProcess"]
|
||||
__all__ = [
|
||||
"ToplocAuditConfig",
|
||||
"ToplocProofClaim",
|
||||
"ValidatorProcess",
|
||||
"AdaptiveAuditSampler",
|
||||
"AuditRateConfig",
|
||||
"detect_output_tripwire",
|
||||
]
|
||||
|
||||
164
packages/validator/meshnet_validator/audit.py
Normal file
164
packages/validator/meshnet_validator/audit.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""TOPLOC activation proof helpers for validator-side audits."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from importlib import import_module
|
||||
from typing import Any, Literal
|
||||
|
||||
|
||||
ProofEncoding = Literal["base64", "bytes"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToplocAuditConfig:
|
||||
"""Canonical audit parameters for one model preset."""
|
||||
|
||||
dtype: str = "bfloat16"
|
||||
quantization: str = "bfloat16"
|
||||
decode_batching_size: int = 32
|
||||
topk: int = 8
|
||||
skip_prefill: bool = True
|
||||
encoding: ProofEncoding = "base64"
|
||||
# ADR-0018 §3: nodes retain boundary activations only briefly; a commitment
|
||||
# older than this can no longer be verified against a live node and must
|
||||
# fall back to the text-only audit path.
|
||||
commitment_ttl_seconds: float = 30.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToplocProofClaim:
|
||||
"""Prover-provided TOPLOC proof and the parameters it was built with."""
|
||||
|
||||
proofs: Any
|
||||
dtype: str
|
||||
quantization: str
|
||||
decode_batching_size: int
|
||||
topk: int
|
||||
skip_prefill: bool = True
|
||||
encoding: ProofEncoding = "base64"
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: dict[str, Any]) -> "ToplocProofClaim":
|
||||
return cls(
|
||||
proofs=value["proofs"],
|
||||
dtype=str(value.get("dtype", "bfloat16")),
|
||||
quantization=str(value.get("quantization", "bfloat16")),
|
||||
decode_batching_size=int(value.get("decode_batching_size", 32)),
|
||||
topk=int(value.get("topk", 8)),
|
||||
skip_prefill=bool(value.get("skip_prefill", True)),
|
||||
encoding=_proof_encoding(value.get("encoding", "base64")),
|
||||
)
|
||||
|
||||
def as_mapping(self) -> dict[str, Any]:
|
||||
return {
|
||||
"proofs": self.proofs,
|
||||
"dtype": self.dtype,
|
||||
"quantization": self.quantization,
|
||||
"decode_batching_size": self.decode_batching_size,
|
||||
"topk": self.topk,
|
||||
"skip_prefill": self.skip_prefill,
|
||||
"encoding": self.encoding,
|
||||
}
|
||||
|
||||
|
||||
def build_activation_proofs(
|
||||
activations: list[Any],
|
||||
*,
|
||||
config: ToplocAuditConfig | None = None,
|
||||
backend: Any | None = None,
|
||||
) -> ToplocProofClaim:
|
||||
"""Build a TOPLOC proof claim from captured activation tensors."""
|
||||
cfg = config or ToplocAuditConfig()
|
||||
module = backend or _load_toploc()
|
||||
function_name = f"build_proofs_{cfg.encoding}"
|
||||
build = getattr(module, function_name)
|
||||
proofs = _call_toploc(
|
||||
build,
|
||||
activations,
|
||||
decode_batching_size=cfg.decode_batching_size,
|
||||
topk=cfg.topk,
|
||||
skip_prefill=cfg.skip_prefill,
|
||||
)
|
||||
return ToplocProofClaim(
|
||||
proofs=proofs,
|
||||
dtype=cfg.dtype,
|
||||
quantization=cfg.quantization,
|
||||
decode_batching_size=cfg.decode_batching_size,
|
||||
topk=cfg.topk,
|
||||
skip_prefill=cfg.skip_prefill,
|
||||
encoding=cfg.encoding,
|
||||
)
|
||||
|
||||
|
||||
def verify_activation_proofs(
|
||||
reference_activations: list[Any],
|
||||
claim: ToplocProofClaim,
|
||||
*,
|
||||
config: ToplocAuditConfig | None = None,
|
||||
backend: Any | None = None,
|
||||
) -> bool:
|
||||
"""Verify prover TOPLOC proofs against reference teacher-forced activations."""
|
||||
cfg = config or ToplocAuditConfig(
|
||||
dtype=claim.dtype,
|
||||
quantization=claim.quantization,
|
||||
decode_batching_size=claim.decode_batching_size,
|
||||
topk=claim.topk,
|
||||
skip_prefill=claim.skip_prefill,
|
||||
encoding=claim.encoding,
|
||||
)
|
||||
if claim.dtype != cfg.dtype or claim.quantization != cfg.quantization:
|
||||
return False
|
||||
if claim.decode_batching_size != cfg.decode_batching_size or claim.topk != cfg.topk:
|
||||
return False
|
||||
if claim.skip_prefill != cfg.skip_prefill or claim.encoding != cfg.encoding:
|
||||
return False
|
||||
|
||||
module = backend or _load_toploc()
|
||||
function_name = f"verify_proofs_{claim.encoding}"
|
||||
verify = getattr(module, function_name)
|
||||
return bool(_call_toploc(
|
||||
verify,
|
||||
reference_activations,
|
||||
claim.proofs,
|
||||
decode_batching_size=claim.decode_batching_size,
|
||||
topk=claim.topk,
|
||||
skip_prefill=claim.skip_prefill,
|
||||
))
|
||||
|
||||
|
||||
def _load_toploc() -> Any:
|
||||
try:
|
||||
return import_module("toploc")
|
||||
except ModuleNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
"toploc is required for activation proof audits; install meshnet-validator with dependencies"
|
||||
) from exc
|
||||
|
||||
|
||||
def _call_toploc(function: Any, activations: list[Any], *args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
return function(activations, *args, **kwargs)
|
||||
except TypeError:
|
||||
if kwargs:
|
||||
ordered = [
|
||||
kwargs["decode_batching_size"],
|
||||
kwargs["topk"],
|
||||
kwargs["skip_prefill"],
|
||||
]
|
||||
return function(activations, *args, *ordered)
|
||||
raise
|
||||
|
||||
|
||||
def _proof_encoding(value: object) -> ProofEncoding:
|
||||
if value == "bytes":
|
||||
return "bytes"
|
||||
return "base64"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ToplocAuditConfig",
|
||||
"ToplocProofClaim",
|
||||
"build_activation_proofs",
|
||||
"verify_activation_proofs",
|
||||
]
|
||||
105
packages/validator/meshnet_validator/sampling.py
Normal file
105
packages/validator/meshnet_validator/sampling.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Reputation-weighted, budget-balanced audit sampling (ADR-0018 §1, §6-7).
|
||||
|
||||
The flat ``sample_rate`` on ``ValidatorProcess`` treats every wallet the same.
|
||||
This module scores each wallet's audit probability from its tenure
|
||||
(``completed_job_count``) and reputation -- newcomers and low-reputation
|
||||
wallets get sampled far more than veterans in good standing, who float down
|
||||
to a floor -- while a running budget balance keeps the *realized* fleet-wide
|
||||
average anchored to a configured target even as the wallet population mix
|
||||
drifts (research §6, §8 layers 2-4).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuditRateConfig:
|
||||
"""Tunable audit-rate policy. Defaults match ADR-0018 §1/§6."""
|
||||
|
||||
target_rate: float = 0.05
|
||||
newcomer_rate: float = 0.25
|
||||
veteran_floor: float = 0.02
|
||||
probation_jobs: int = 50
|
||||
veteran_jobs: int = 500
|
||||
tripwire_multiplier: float = 3.0
|
||||
# Bounds the correction the budget balancer can apply in one step so a
|
||||
# short burst of skewed traffic can't swing everyone's rate wildly.
|
||||
max_budget_scale: float = 20.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not 0.0 <= self.target_rate <= 1.0:
|
||||
raise ValueError("target_rate must be between 0 and 1")
|
||||
if not 0.0 <= self.veteran_floor <= self.newcomer_rate <= 1.0:
|
||||
raise ValueError("veteran_floor must be <= newcomer_rate, both within [0, 1]")
|
||||
if self.probation_jobs < 0 or self.veteran_jobs <= self.probation_jobs:
|
||||
raise ValueError("veteran_jobs must be greater than probation_jobs")
|
||||
if self.tripwire_multiplier < 1.0:
|
||||
raise ValueError("tripwire_multiplier must be >= 1.0")
|
||||
|
||||
|
||||
class AdaptiveAuditSampler:
|
||||
"""Per-wallet audit probability + fleet-wide budget balancer.
|
||||
|
||||
``wallet_base_rate`` scores tenure and reputation into a rate between
|
||||
``veteran_floor`` and ``newcomer_rate``. ``should_audit`` scales that rate
|
||||
by a running budget-balance factor (target_rate / historical mean base
|
||||
rate) so the fleet-wide realized audit rate tracks ``target_rate`` even
|
||||
when the wallet mix is skewed toward veterans or newcomers, then applies
|
||||
the tripwire multiplier to *that single decision only* -- a tripwire flag
|
||||
never touches the shared budget-balance history, so it can't punish
|
||||
other wallets' audit rate.
|
||||
"""
|
||||
|
||||
def __init__(self, config: AuditRateConfig | None = None, *, random_seed: int | None = None) -> None:
|
||||
self.config = config or AuditRateConfig()
|
||||
self._random = random.Random(random_seed)
|
||||
self._base_rate_total = 0.0
|
||||
self._decisions = 0
|
||||
|
||||
def wallet_base_rate(self, *, completed_job_count: int, reputation: float) -> float:
|
||||
cfg = self.config
|
||||
if completed_job_count <= cfg.probation_jobs:
|
||||
tenure_rate = cfg.newcomer_rate
|
||||
elif completed_job_count >= cfg.veteran_jobs:
|
||||
tenure_rate = cfg.veteran_floor
|
||||
else:
|
||||
span = cfg.veteran_jobs - cfg.probation_jobs
|
||||
frac = (completed_job_count - cfg.probation_jobs) / span
|
||||
tenure_rate = cfg.newcomer_rate + (cfg.veteran_floor - cfg.newcomer_rate) * frac
|
||||
|
||||
reputation_gap = max(0.0, min(1.0, 1.0 - reputation))
|
||||
reputation_rate = cfg.veteran_floor + reputation_gap * (cfg.newcomer_rate - cfg.veteran_floor)
|
||||
|
||||
return max(tenure_rate, reputation_rate, cfg.veteran_floor)
|
||||
|
||||
def sample_rate_for(self, *, completed_job_count: int, reputation: float, tripwire: bool = False) -> float:
|
||||
"""Preview the effective audit probability without recording a decision."""
|
||||
base_rate = self.wallet_base_rate(completed_job_count=completed_job_count, reputation=reputation)
|
||||
return self._effective_rate(base_rate, tripwire=tripwire)
|
||||
|
||||
def should_audit(self, *, completed_job_count: int, reputation: float, tripwire: bool = False) -> bool:
|
||||
base_rate = self.wallet_base_rate(completed_job_count=completed_job_count, reputation=reputation)
|
||||
effective_rate = self._effective_rate(base_rate, tripwire=tripwire)
|
||||
self._base_rate_total += base_rate
|
||||
self._decisions += 1
|
||||
return self._random.random() < effective_rate
|
||||
|
||||
def _effective_rate(self, base_rate: float, *, tripwire: bool) -> float:
|
||||
rate = base_rate * self._budget_scale()
|
||||
if tripwire:
|
||||
rate *= self.config.tripwire_multiplier
|
||||
return max(self.config.veteran_floor, min(1.0, rate))
|
||||
|
||||
def _budget_scale(self) -> float:
|
||||
if self._decisions == 0:
|
||||
return 1.0
|
||||
mean_base_rate = self._base_rate_total / self._decisions
|
||||
if mean_base_rate <= 0:
|
||||
return 1.0
|
||||
return min(self.config.max_budget_scale, self.config.target_rate / mean_base_rate)
|
||||
|
||||
|
||||
__all__ = ["AuditRateConfig", "AdaptiveAuditSampler"]
|
||||
36
packages/validator/meshnet_validator/tripwire.py
Normal file
36
packages/validator/meshnet_validator/tripwire.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Passive output-quality tripwires (ADR-0018 §7).
|
||||
|
||||
Cheap heuristics over every completed request's text -- no model access, no
|
||||
extra inference -- that flag likely-degenerate output (repetition loops,
|
||||
truncation) so ``AdaptiveAuditSampler`` can bump that single request's audit
|
||||
probability. A flag never strikes or bans a wallet by itself; it only raises
|
||||
the odds that request gets a real audit (research §8 layer 5).
|
||||
|
||||
True perplexity scoring needs the serving model's logits, which the validator
|
||||
does not have outside an audit call, so it stays roadmap-only (ADR-0018 §8);
|
||||
this covers the repetition/truncation half of the passive-tripwire layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def detect_output_tripwire(
|
||||
text: str,
|
||||
*,
|
||||
min_words: int = 4,
|
||||
repetition_threshold: float = 0.4,
|
||||
) -> bool:
|
||||
"""Flag empty output or a single word/token dominating the response."""
|
||||
if not text or not text.strip():
|
||||
return True
|
||||
words = text.split()
|
||||
if len(words) < min_words:
|
||||
return False
|
||||
counts: dict[str, int] = {}
|
||||
for word in words:
|
||||
counts[word] = counts.get(word, 0) + 1
|
||||
most_common = max(counts.values())
|
||||
return (most_common / len(words)) >= repetition_threshold
|
||||
|
||||
|
||||
__all__ = ["detect_output_tripwire"]
|
||||
@@ -7,6 +7,9 @@ name = "meshnet-validator"
|
||||
version = "0.1.0"
|
||||
description = "Optimistic fraud validator for the Distributed Inference Network"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"toploc>=0.1",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["meshnet_validator*"]
|
||||
|
||||
Reference in New Issue
Block a user