myco-bonding-curve/tests/test_conviction.py

473 lines
15 KiB
Python

"""Tests for conviction voting governance (src/primitives/conviction.py)."""
import math
import numpy as np
import pytest
from src.primitives.conviction import (
ConvictionParams,
Proposal,
Voter,
ConvictionSystem,
trigger_threshold,
update_conviction,
max_conviction,
conviction_at_time,
epochs_to_fraction,
stake,
unstake,
tick,
generate_conviction_curves,
get_governance_metrics,
)
# ---------- Helpers ----------
def make_system(n_voters: int = 3, holdings_each: float = 10_000.0) -> ConvictionSystem:
"""Return a ConvictionSystem with a few voters."""
params = ConvictionParams(alpha=0.9, beta=0.2, rho=0.0025, min_age=3)
system = ConvictionSystem(
params=params,
total_supply=n_voters * holdings_each,
total_funds=50_000.0,
)
for i in range(n_voters):
system.voters[f"voter_{i}"] = Voter(
id=f"voter_{i}",
holdings=holdings_each,
sentiment=0.7,
)
return system
def add_proposal(
system: ConvictionSystem,
prop_id: str = "p1",
funds_requested: float = 0.05,
) -> Proposal:
prop = Proposal(
id=prop_id,
title="Test Proposal",
funds_requested=funds_requested,
)
system.proposals[prop_id] = prop
return prop
# ---------- ConvictionParams ----------
class TestConvictionParams:
def test_defaults(self):
p = ConvictionParams()
assert p.alpha == 0.9
assert p.beta == 0.2
assert p.rho == 0.0025
assert p.min_age == 3
def test_half_life_formula(self):
p = ConvictionParams(alpha=0.5)
# T_half = -ln(2)/ln(0.5) = 1.0
assert p.half_life == pytest.approx(1.0)
def test_half_life_alpha_09(self):
p = ConvictionParams(alpha=0.9)
expected = -np.log(2) / np.log(0.9)
assert p.half_life == pytest.approx(expected)
def test_half_life_alpha_zero_is_inf(self):
p = ConvictionParams(alpha=0.0)
assert p.half_life == float("inf")
def test_from_half_life_roundtrip(self):
target_hl = 10.0
p = ConvictionParams.from_half_life(target_hl)
assert p.half_life == pytest.approx(target_hl, rel=1e-6)
def test_from_half_life_kwargs_forwarded(self):
p = ConvictionParams.from_half_life(10.0, beta=0.3, min_age=5)
assert p.beta == 0.3
assert p.min_age == 5
# ---------- update_conviction ----------
class TestUpdateConviction:
def test_formula_y_t_plus_1(self):
alpha = 0.9
y_t = 100.0
x = 50.0
result = update_conviction(y_t, x, alpha)
assert result == pytest.approx(alpha * y_t + x)
def test_zero_initial_conviction(self):
result = update_conviction(0.0, 100.0, 0.9)
assert result == pytest.approx(100.0)
def test_zero_staked(self):
result = update_conviction(200.0, 0.0, 0.9)
assert result == pytest.approx(0.9 * 200.0)
def test_convergence_behavior(self):
# Repeatedly applying with same stake should converge to max_conviction
x = 100.0
alpha = 0.9
y = 0.0
for _ in range(1000):
y = update_conviction(y, x, alpha)
assert y == pytest.approx(max_conviction(x, alpha), rel=1e-4)
# ---------- max_conviction ----------
class TestMaxConviction:
def test_formula(self):
x = 100.0
alpha = 0.9
assert max_conviction(x, alpha) == pytest.approx(x / (1 - alpha))
def test_alpha_one_is_inf(self):
assert max_conviction(100.0, 1.0) == float("inf")
def test_higher_alpha_higher_max(self):
x = 100.0
assert max_conviction(x, 0.95) > max_conviction(x, 0.5)
# ---------- conviction_at_time ----------
class TestConvictionAtTime:
def test_epoch_zero_equals_initial(self):
result = conviction_at_time(100.0, 0.9, 0, initial_conviction=50.0)
assert result == pytest.approx(50.0)
def test_epoch_one_matches_update_formula(self):
x = 100.0
alpha = 0.9
y0 = 50.0
result = conviction_at_time(x, alpha, 1, y0)
expected = update_conviction(y0, x, alpha)
assert result == pytest.approx(expected)
def test_matches_iterative_application(self):
x = 75.0
alpha = 0.85
y0 = 0.0
n = 20
# Iterate manually
y = y0
for _ in range(n):
y = update_conviction(y, x, alpha)
assert conviction_at_time(x, alpha, n, y0) == pytest.approx(y, rel=1e-9)
def test_approaches_max_conviction(self):
x = 100.0
alpha = 0.9
y_large = conviction_at_time(x, alpha, 500)
assert y_large == pytest.approx(max_conviction(x, alpha), rel=1e-3)
# ---------- epochs_to_fraction ----------
class TestEpochsToFraction:
def test_half_is_half_life(self):
alpha = 0.9
p = ConvictionParams(alpha=alpha)
t_half = epochs_to_fraction(0.5, alpha)
assert t_half == pytest.approx(p.half_life, rel=1e-6)
def test_fraction_zero_is_inf(self):
assert epochs_to_fraction(0.0, 0.9) == float("inf")
def test_fraction_one_is_inf(self):
assert epochs_to_fraction(1.0, 0.9) == float("inf")
def test_higher_fraction_takes_longer(self):
alpha = 0.9
t_50 = epochs_to_fraction(0.5, alpha)
t_90 = epochs_to_fraction(0.9, alpha)
assert t_90 > t_50
# ---------- trigger_threshold ----------
class TestTriggerThreshold:
def test_formula(self):
p = ConvictionParams(alpha=0.9, beta=0.2, rho=0.0025)
supply = 100_000.0
share = 0.05
expected = p.rho * supply / ((1 - p.alpha) * (p.beta - share) ** 2)
assert trigger_threshold(share, supply, p) == pytest.approx(expected)
def test_share_at_beta_returns_inf(self):
p = ConvictionParams(beta=0.2)
result = trigger_threshold(0.2, 100_000.0, p)
assert result == float("inf")
def test_share_above_beta_returns_inf(self):
p = ConvictionParams(beta=0.2)
result = trigger_threshold(0.25, 100_000.0, p)
assert result == float("inf")
def test_larger_supply_higher_threshold(self):
p = ConvictionParams()
t1 = trigger_threshold(0.05, 100_000.0, p)
t2 = trigger_threshold(0.05, 200_000.0, p)
assert t2 > t1
def test_smaller_share_lower_threshold(self):
p = ConvictionParams(beta=0.2)
t_small = trigger_threshold(0.01, 100_000.0, p)
t_large = trigger_threshold(0.1, 100_000.0, p)
assert t_small < t_large
# ---------- Voter stake/unstake ----------
class TestVoterStaking:
def test_stake_adds_to_voter_stakes(self):
system = make_system()
add_proposal(system, "p1", 0.05)
stake(system, "voter_0", "p1", 500.0)
assert system.voters["voter_0"].stakes.get("p1", 0.0) == pytest.approx(500.0)
def test_stake_capped_at_holdings(self):
system = make_system(holdings_each=1_000.0)
add_proposal(system, "p1")
stake(system, "voter_0", "p1", 999_999.0)
assert system.voters["voter_0"].stakes.get("p1", 0.0) <= 1_000.0
def test_stake_on_unknown_voter_is_noop(self):
system = make_system()
add_proposal(system, "p1")
result = stake(system, "nobody", "p1", 100.0)
assert result is system # No crash, no change
def test_stake_on_unknown_proposal_is_noop(self):
system = make_system()
result = stake(system, "voter_0", "nonexistent", 100.0)
assert result is system
def test_unstake_removes_tokens(self):
system = make_system()
add_proposal(system, "p1")
stake(system, "voter_0", "p1", 500.0)
unstake(system, "voter_0", "p1", 200.0)
assert system.voters["voter_0"].stakes.get("p1", 0.0) == pytest.approx(300.0)
def test_unstake_all_removes_key(self):
system = make_system()
add_proposal(system, "p1")
stake(system, "voter_0", "p1", 500.0)
unstake(system, "voter_0", "p1", 500.0)
assert "p1" not in system.voters["voter_0"].stakes
def test_unstake_capped_at_staked(self):
system = make_system()
add_proposal(system, "p1")
stake(system, "voter_0", "p1", 200.0)
unstake(system, "voter_0", "p1", 999_999.0)
assert system.voters["voter_0"].stakes.get("p1", 0.0) == pytest.approx(0.0)
def test_stake_accumulates_on_multiple_calls(self):
system = make_system(holdings_each=10_000.0)
add_proposal(system, "p1")
stake(system, "voter_0", "p1", 200.0)
stake(system, "voter_0", "p1", 300.0)
assert system.voters["voter_0"].stakes.get("p1", 0.0) == pytest.approx(500.0)
def test_cannot_stake_more_than_available_across_proposals(self):
system = make_system(holdings_each=1_000.0)
add_proposal(system, "p1")
add_proposal(system, "p2")
stake(system, "voter_0", "p1", 800.0)
stake(system, "voter_0", "p2", 500.0) # Only 200 left
assert system.voters["voter_0"].stakes.get("p2", 0.0) == pytest.approx(200.0)
# ---------- tick ----------
class TestTick:
def test_tick_increments_epoch(self):
system = make_system()
tick(system)
assert system.epoch == 1
def test_tick_updates_conviction(self):
system = make_system()
add_proposal(system, "p1")
stake(system, "voter_0", "p1", 500.0)
tick(system)
assert system.voters["voter_0"].convictions.get("p1", 0.0) > 0.0
def test_tick_increments_proposal_age(self):
system = make_system()
add_proposal(system, "p1")
tick(system)
assert system.proposals["p1"].age == 1
def test_proposal_does_not_pass_before_min_age(self):
system = make_system(n_voters=5, holdings_each=100_000.0)
add_proposal(system, "p1", funds_requested=0.001)
# Stake heavily
for vid in system.voters:
stake(system, vid, "p1", 90_000.0)
# Tick fewer times than min_age (=3)
tick(system)
tick(system)
assert system.proposals["p1"].status == "candidate"
def test_proposal_passes_after_sufficient_conviction(self):
"""Create conditions where a proposal accumulates enough conviction."""
params = ConvictionParams(alpha=0.9, beta=0.5, rho=0.0001, min_age=1)
system = ConvictionSystem(
params=params,
total_supply=1_000_000.0,
total_funds=100_000.0,
)
# One whale voter with enormous holdings
system.voters["whale"] = Voter(id="whale", holdings=1_000_000.0)
prop = Proposal(id="p1", title="Easy Pass", funds_requested=0.001)
system.proposals["p1"] = prop
# Stake all holdings
stake(system, "whale", "p1", 1_000_000.0)
# Tick many times to build conviction far above threshold
for _ in range(100):
if system.proposals["p1"].status == "passed":
break
tick(system)
assert system.proposals["p1"].status == "passed"
assert len(system.passed_proposals) == 1
# ---------- generate_conviction_curves ----------
class TestGenerateConvictionCurves:
def test_returns_correct_keys(self):
curves = generate_conviction_curves(100.0, 0.9, epochs=50)
assert "time" in curves
assert "charge" in curves
assert "discharge" in curves
assert "max" in curves
def test_correct_shape(self):
epochs = 30
curves = generate_conviction_curves(100.0, 0.9, epochs=epochs)
assert len(curves["time"]) == epochs
assert len(curves["charge"]) == epochs
assert len(curves["discharge"]) == epochs
assert len(curves["max"]) == epochs
def test_charge_starts_near_zero(self):
curves = generate_conviction_curves(100.0, 0.9, epochs=50)
# At t=0, charge = x*(1 - alpha^0)/(1-alpha) = x*0/(1-alpha) = 0
assert curves["charge"][0] == pytest.approx(0.0)
def test_charge_approaches_max(self):
curves = generate_conviction_curves(100.0, 0.9, epochs=500)
y_max = max_conviction(100.0, 0.9)
assert curves["charge"][-1] == pytest.approx(y_max, rel=1e-3)
def test_discharge_starts_at_max(self):
curves = generate_conviction_curves(100.0, 0.9, epochs=50)
y_max = max_conviction(100.0, 0.9)
assert curves["discharge"][0] == pytest.approx(y_max)
def test_discharge_decays(self):
curves = generate_conviction_curves(100.0, 0.9, epochs=50)
assert curves["discharge"][-1] < curves["discharge"][0]
def test_max_array_constant(self):
curves = generate_conviction_curves(100.0, 0.9, epochs=20)
assert np.all(curves["max"] == curves["max"][0])
# ---------- get_governance_metrics ----------
class TestGetGovernanceMetrics:
def test_returns_expected_fields(self):
system = make_system()
m = get_governance_metrics(system)
assert "epoch" in m
assert "total_supply" in m
assert "total_staked" in m
assert "staking_ratio" in m
assert "active_proposals" in m
assert "passed_proposals" in m
assert "total_voters" in m
assert "proposals" in m
def test_voter_count_correct(self):
system = make_system(n_voters=4)
m = get_governance_metrics(system)
assert m["total_voters"] == 4
def test_staking_ratio_zero_when_nothing_staked(self):
system = make_system()
m = get_governance_metrics(system)
assert m["staking_ratio"] == pytest.approx(0.0)
def test_staking_ratio_updates_after_stake(self):
system = make_system(n_voters=1, holdings_each=10_000.0)
add_proposal(system, "p1")
stake(system, "voter_0", "p1", 5_000.0)
m = get_governance_metrics(system)
assert m["staking_ratio"] == pytest.approx(5_000.0 / 10_000.0)
def test_active_proposals_count(self):
system = make_system()
add_proposal(system, "p1")
add_proposal(system, "p2")
m = get_governance_metrics(system)
assert m["active_proposals"] == 2
def test_passed_proposals_count(self):
system = make_system()
dummy_proposal = Proposal(id="done", title="Done", status="passed")
system.passed_proposals.append(dummy_proposal)
m = get_governance_metrics(system)
assert m["passed_proposals"] == 1
# ---------- Full governance lifecycle ----------
class TestFullGovernanceLifecycle:
def test_lifecycle(self):
"""Create proposals, stake, tick until passage."""
params = ConvictionParams(alpha=0.9, beta=0.5, rho=0.0001, min_age=2)
system = ConvictionSystem(
params=params,
total_supply=500_000.0,
total_funds=100_000.0,
)
# Create voters
for i in range(5):
system.voters[f"v{i}"] = Voter(id=f"v{i}", holdings=100_000.0)
# Create a low-threshold proposal
prop = Proposal(id="easy", title="Easy", funds_requested=0.001)
system.proposals["easy"] = prop
# All voters stake
for i in range(5):
stake(system, f"v{i}", "easy", 80_000.0)
# Tick until passed or timeout
passed = False
for _ in range(200):
tick(system)
if system.proposals["easy"].status == "passed":
passed = True
break
assert passed
assert len(system.passed_proposals) == 1
# Verify metrics reflect state
m = get_governance_metrics(system)
assert m["passed_proposals"] == 1
assert m["epoch"] > 0