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:
Dobromir Popov
2026-07-05 21:47:23 +03:00
parent c967e5cfc4
commit 9abe83b5f4
45 changed files with 4095 additions and 774 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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": [

View File

@@ -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"])

View File

@@ -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,

View File

@@ -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": [

View File

@@ -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:

View 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

View File

@@ -9,6 +9,7 @@ description = "Distributed Inference Network node registry and route selection"
requires-python = ">=3.10"
dependencies = [
"cryptography>=41",
"websockets>=13",
]

View File

@@ -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 2030%, 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.

View File

@@ -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",
]

View 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",
]

View 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"]

View 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"]

View File

@@ -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*"]