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).
228 lines
8.1 KiB
Python
228 lines
8.1 KiB
Python
"""Issue 08 (FRAUD: reputation model): scoring rules on top of the persisted
|
||
reputation/strike/ban fields from issue 05 (ADR-0018 §6).
|
||
|
||
Score derives only from tracker audit outcomes + uptime/latency: slow build,
|
||
instant loss, inactivity decay. Strikes apply a ×0.8 routing multiplier per
|
||
strike, separate from the (full) forfeiture penalty owned by issue 10.
|
||
"""
|
||
|
||
import json
|
||
import urllib.request
|
||
|
||
import pytest
|
||
|
||
from meshnet_contracts import (
|
||
REPUTATION_CLEAN_AUDIT_DELTA,
|
||
REPUTATION_FAILED_AUDIT_DELTA,
|
||
LocalSolanaContracts,
|
||
)
|
||
from meshnet_node.server import StubNodeServer
|
||
from meshnet_validator import ValidatorProcess
|
||
|
||
MODEL = "stub-model"
|
||
|
||
|
||
# ---- documented deltas, applied via the persisted issue-05 fields ----
|
||
|
||
|
||
def test_clean_audit_increases_reputation_by_documented_delta():
|
||
contracts = LocalSolanaContracts()
|
||
contracts.registry.record_audit_outcome("wallet-a", passed=False) # 1.0 -> 0.7
|
||
before = contracts.registry.get_wallet("wallet-a").reputation
|
||
|
||
contracts.registry.record_audit_outcome("wallet-a", passed=True)
|
||
|
||
after = contracts.registry.get_wallet("wallet-a").reputation
|
||
assert after == pytest.approx(before + REPUTATION_CLEAN_AUDIT_DELTA)
|
||
|
||
|
||
def test_failed_audit_decreases_reputation_by_documented_delta():
|
||
contracts = LocalSolanaContracts()
|
||
contracts.registry.record_audit_outcome("wallet-b", passed=False)
|
||
assert contracts.registry.get_wallet("wallet-b").reputation == pytest.approx(
|
||
1.0 + REPUTATION_FAILED_AUDIT_DELTA
|
||
)
|
||
|
||
|
||
def test_reputation_clamps_to_documented_bounds():
|
||
contracts = LocalSolanaContracts()
|
||
for _ in range(10):
|
||
contracts.registry.record_audit_outcome("wallet-c", passed=False)
|
||
assert contracts.registry.get_wallet("wallet-c").reputation == 0.0
|
||
|
||
for _ in range(30):
|
||
contracts.registry.record_audit_outcome("wallet-c", passed=True)
|
||
assert contracts.registry.get_wallet("wallet-c").reputation == 1.0
|
||
|
||
|
||
def test_reputation_uses_persisted_fields_not_a_second_schema(tmp_path):
|
||
"""Red (issue 08 test-first item 1): reputation deltas must persist and
|
||
reload through the same event log issue 05 already wired — no parallel
|
||
storage path."""
|
||
db = str(tmp_path / "registry.sqlite")
|
||
contracts = LocalSolanaContracts(registry_db=db)
|
||
contracts.registry.record_audit_outcome("wallet-d", passed=False)
|
||
contracts.save_to_db()
|
||
|
||
reopened = LocalSolanaContracts(registry_db=db)
|
||
wallet = reopened.registry.get_wallet("wallet-d")
|
||
assert wallet.reputation == pytest.approx(1.0 + REPUTATION_FAILED_AUDIT_DELTA)
|
||
assert wallet.strike_count == 0 # audit_outcome never strikes on its own
|
||
|
||
|
||
# ---- strike -> routing multiplier, separate from forfeiture ----
|
||
|
||
|
||
def test_strike_applies_graduated_routing_multiplier_not_full_penalty():
|
||
contracts = LocalSolanaContracts()
|
||
contracts.registry.submit_stake("wallet-e", 500)
|
||
assert contracts.registry.routing_multiplier("wallet-e") == pytest.approx(1.0)
|
||
|
||
contracts.registry.record_strike("wallet-e", ban_threshold=10)
|
||
assert contracts.registry.routing_multiplier("wallet-e") == pytest.approx(0.8)
|
||
|
||
contracts.registry.record_strike("wallet-e", ban_threshold=10)
|
||
assert contracts.registry.routing_multiplier("wallet-e") == pytest.approx(0.8 * 0.8)
|
||
# stake (forfeiture surface) is untouched by strikes alone
|
||
assert contracts.registry.get_wallet("wallet-e").stake_balance == 500
|
||
|
||
|
||
def test_three_strikes_still_bans_and_probation_still_enforced():
|
||
contracts = LocalSolanaContracts(probationary_job_count=2)
|
||
for _ in range(3):
|
||
contracts.registry.record_strike("wallet-f")
|
||
wallet = contracts.registry.get_wallet("wallet-f")
|
||
assert wallet.strike_count == 3
|
||
assert wallet.banned is True
|
||
|
||
contracts.registry.record_completed_job("wallet-g")
|
||
assert contracts.registry.probationary_jobs_remaining("wallet-g") == 1
|
||
|
||
|
||
# ---- inactivity decay ----
|
||
|
||
|
||
def test_inactivity_decay_after_idle_days_without_completed_jobs():
|
||
contracts = LocalSolanaContracts()
|
||
contracts.registry.record_completed_jobs("wallet-h", 1, ts=0.0)
|
||
|
||
idle_seconds = 30 * 86400.0
|
||
decayed = contracts.registry.apply_inactivity_decay(
|
||
idle_seconds=idle_seconds, decay_amount=0.05, now=idle_seconds + 1.0,
|
||
)
|
||
|
||
assert "wallet-h" in decayed
|
||
assert contracts.registry.get_wallet("wallet-h").reputation == pytest.approx(0.95)
|
||
|
||
|
||
def test_inactivity_decay_skips_wallets_active_within_the_window():
|
||
contracts = LocalSolanaContracts()
|
||
contracts.registry.record_completed_jobs("wallet-i", 1, ts=1000.0)
|
||
|
||
decayed = contracts.registry.apply_inactivity_decay(
|
||
idle_seconds=30 * 86400.0, now=1000.0 + 60.0,
|
||
)
|
||
|
||
assert decayed == {}
|
||
assert contracts.registry.get_wallet("wallet-i").reputation == pytest.approx(1.0)
|
||
|
||
|
||
def test_inactivity_decay_applies_at_most_once_per_idle_window():
|
||
contracts = LocalSolanaContracts()
|
||
contracts.registry.record_completed_jobs("wallet-j", 1, ts=0.0)
|
||
idle_seconds = 30 * 86400.0
|
||
|
||
contracts.registry.apply_inactivity_decay(idle_seconds=idle_seconds, now=idle_seconds + 1.0)
|
||
again = contracts.registry.apply_inactivity_decay(idle_seconds=idle_seconds, now=idle_seconds + 2.0)
|
||
|
||
assert again == {}
|
||
assert contracts.registry.get_wallet("wallet-j").reputation == pytest.approx(0.95)
|
||
|
||
|
||
def test_inactivity_decay_skips_banned_wallets():
|
||
contracts = LocalSolanaContracts()
|
||
contracts.registry.record_completed_jobs("wallet-k", 1, ts=0.0)
|
||
contracts.registry.ban_wallet("wallet-k")
|
||
|
||
decayed = contracts.registry.apply_inactivity_decay(now=30 * 86400.0 + 1.0)
|
||
|
||
assert decayed == {}
|
||
|
||
|
||
# ---- no peer-to-peer reputation inputs ----
|
||
|
||
|
||
def test_registry_contract_has_no_peer_rating_api():
|
||
contracts = LocalSolanaContracts()
|
||
for forbidden in ("rate_peer", "submit_peer_rating", "peer_reputation"):
|
||
assert not hasattr(contracts.registry, forbidden)
|
||
|
||
|
||
# ---- validator wiring: audit outcomes feed reputation end-to-end ----
|
||
|
||
|
||
def _record_event(contracts, session_id: str, output: str, wallet: str) -> None:
|
||
contracts.validation.record_completed_inference(
|
||
session_id=session_id,
|
||
model=MODEL,
|
||
messages=[{"role": "user", "content": "2+2"}],
|
||
observed_output=output,
|
||
route_nodes=[{"wallet_address": wallet, "shard_end": 31}],
|
||
)
|
||
|
||
|
||
@pytest.fixture
|
||
def reference_node():
|
||
node = StubNodeServer(shard_start=0, shard_end=31, is_last_shard=True)
|
||
port = node.start()
|
||
yield f"http://127.0.0.1:{port}"
|
||
node.stop()
|
||
|
||
|
||
def _reference_output(reference_url: str) -> str:
|
||
req = urllib.request.Request(
|
||
f"{reference_url}/v1/infer",
|
||
data=json.dumps({"messages": [{"role": "user", "content": "2+2"}]}).encode(),
|
||
headers={"Content-Type": "application/json"},
|
||
method="POST",
|
||
)
|
||
with urllib.request.urlopen(req) as r:
|
||
return json.loads(r.read())["text"]
|
||
|
||
|
||
def test_validator_credits_clean_audit_via_persisted_reputation(reference_node):
|
||
contracts = LocalSolanaContracts()
|
||
contracts.registry.record_audit_outcome("wallet-good", passed=False) # 1.0 -> 0.7
|
||
validator = ValidatorProcess(
|
||
contracts=contracts, reference_node_url=reference_node, sample_rate=1.0, random_seed=7,
|
||
)
|
||
_record_event(contracts, "sess-clean", _reference_output(reference_node), "wallet-good")
|
||
|
||
validator.validate_once()
|
||
|
||
assert contracts.registry.get_wallet("wallet-good").reputation == pytest.approx(
|
||
0.7 + REPUTATION_CLEAN_AUDIT_DELTA
|
||
)
|
||
|
||
|
||
def test_validator_docks_reputation_on_failed_audit_without_double_striking(reference_node):
|
||
contracts = LocalSolanaContracts()
|
||
contracts.registry.submit_stake("wallet-bad", 500)
|
||
validator = ValidatorProcess(
|
||
contracts=contracts,
|
||
reference_node_url=reference_node,
|
||
sample_rate=1.0,
|
||
slash_amount=100,
|
||
strike_threshold=3,
|
||
random_seed=7,
|
||
)
|
||
_record_event(contracts, "sess-fraud", "fraudulent nonsense", "wallet-bad")
|
||
|
||
validator.validate_once()
|
||
|
||
wallet = contracts.registry.get_wallet("wallet-bad")
|
||
assert wallet.reputation == pytest.approx(1.0 + REPUTATION_FAILED_AUDIT_DELTA)
|
||
# exactly one strike from submit_slash_proof — record_audit_outcome must
|
||
# not add a second
|
||
assert wallet.strike_count == 1
|