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:
227
tests/test_reputation_scoring.py
Normal file
227
tests/test_reputation_scoring.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user