Files
neuron-tai/tests/test_reputation_scoring.py
Dobromir Popov 9abe83b5f4 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).
2026-07-05 21:47:23 +03:00

228 lines
8.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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