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