Skip to content

Commit

Permalink
Merge pull request #698 from SiaFoundation/chris/collateral-ratio
Browse files Browse the repository at this point in the history
Update collateral scoring
  • Loading branch information
ChrisSchinnerl authored Nov 2, 2023
2 parents ed9b31a + 080bf1a commit a2cd963
Show file tree
Hide file tree
Showing 3 changed files with 103 additions and 89 deletions.
3 changes: 3 additions & 0 deletions autopilot/host_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ func newTestHostPriceTable() rhpv3.HostPriceTable {
DownloadBandwidthCost: dlbwPrice,
UploadBandwidthCost: ulbwPrice,

CollateralCost: types.Siacoins(1).Div64(1 << 40),
MaxCollateral: types.Siacoins(10000),

ReadBaseCost: types.NewCurrency64(1),
WriteBaseCost: oneSC.Div64(1 << 40),
WriteStoreCost: oneSC.Div64(4032).Div64(1 << 40), // 1 SC / TiB / month
Expand Down
103 changes: 54 additions & 49 deletions autopilot/hostscore.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,27 @@ import (
)

func hostScore(cfg api.AutopilotConfig, h hostdb.Host, storedData uint64, expectedRedundancy float64) api.HostScoreBreakdown {
// idealDataPerHost is the amount of data that we would have to put on each
// host assuming that our storage requirements were spread evenly across
// every single host.
idealDataPerHost := float64(cfg.Contracts.Storage) * expectedRedundancy / float64(cfg.Contracts.Amount)
// allocationPerHost is the amount of data that we would like to be able to
// put on each host, because data is not always spread evenly across the
// hosts during upload. Slower hosts may get very little data, more
// expensive hosts may get very little data, and other factors can skew the
// distribution. allocationPerHost takes into account the skew and tries to
// ensure that there's enough allocation per host to accommodate for a skew.
// NOTE: assume that data is not spread evenly and the host with the most
// data will store twice the expectation
allocationPerHost := idealDataPerHost * 2
// hostPeriodCost is the amount of money we expect to spend on a host in a period.
hostPeriodCost := hostPeriodCostForScore(h, cfg, expectedRedundancy)
return api.HostScoreBreakdown{
Age: ageScore(h),
Collateral: collateralScore(cfg, hostPeriodCost, h.Settings, expectedRedundancy),
Collateral: collateralScore(cfg, h.PriceTable.HostPriceTable, uint64(allocationPerHost)),
Interactions: interactionScore(h),
Prices: priceAdjustmentScore(hostPeriodCost, cfg),
StorageRemaining: storageRemainingScore(cfg, h.Settings, storedData, expectedRedundancy),
StorageRemaining: storageRemainingScore(cfg, h.Settings, storedData, expectedRedundancy, allocationPerHost),
Uptime: uptimeScore(h),
Version: versionScore(h.Settings),
}
Expand Down Expand Up @@ -58,20 +72,7 @@ func priceAdjustmentScore(hostCostPerPeriod types.Currency, cfg api.AutopilotCon
panic("unreachable")
}

func storageRemainingScore(cfg api.AutopilotConfig, h rhpv2.HostSettings, storedData uint64, expectedRedundancy float64) float64 {
// idealDataPerHost is the amount of data that we would have to put on each
// host assuming that our storage requirements were spread evenly across
// every single host.
idealDataPerHost := float64(cfg.Contracts.Storage) * expectedRedundancy / float64(cfg.Contracts.Amount)
// allocationPerHost is the amount of data that we would like to be able to
// put on each host, because data is not always spread evenly across the
// hosts during upload. Slower hosts may get very little data, more
// expensive hosts may get very little data, and other factors can skew the
// distribution. allocationPerHost takes into account the skew and tries to
// ensure that there's enough allocation per host to accommodate for a skew.
// NOTE: assume that data is not spread evenly and the host with the most
// data will store twice the expectation
allocationPerHost := idealDataPerHost * 2
func storageRemainingScore(cfg api.AutopilotConfig, h rhpv2.HostSettings, storedData uint64, expectedRedundancy, allocationPerHost float64) float64 {
// hostExpectedStorage is the amount of storage that we expect to be able to
// store on this host overall, which should include the stored data that is
// already on the host.
Expand Down Expand Up @@ -122,54 +123,58 @@ func ageScore(h hostdb.Host) float64 {
return weight
}

func collateralScore(cfg api.AutopilotConfig, hostCostPerPeriod types.Currency, s rhpv2.HostSettings, expectedRedundancy float64) float64 {
func collateralScore(cfg api.AutopilotConfig, pt rhpv3.HostPriceTable, allocationPerHost uint64) float64 {
// Ignore hosts which have set their max collateral to 0.
if s.MaxCollateral.IsZero() || s.Collateral.IsZero() {
if pt.MaxCollateral.IsZero() || pt.CollateralCost.IsZero() {
return 0
}

// convenience variables
duration := cfg.Contracts.Period
storage := float64(cfg.Contracts.Storage) * expectedRedundancy

// calculate the expected collateral
expectedCollateral := s.Collateral.Mul64(uint64(storage)).Mul64(duration)
expectedCollateralMax := s.MaxCollateral.Div64(2) // 2x buffer - renter may end up storing extra data
if expectedCollateral.Cmp(expectedCollateralMax) > 0 {
expectedCollateral = expectedCollateralMax
ratioNum := uint64(3)
ratioDenom := uint64(2)

// compute the cost of storing
numSectors := bytesToSectors(allocationPerHost)
storageCost := pt.AppendSectorCost(cfg.Contracts.Period).Storage.Mul64(numSectors)

// calculate the expected collateral for the host allocation.
expectedCollateral := pt.CollateralCost.Mul64(allocationPerHost).Mul64(cfg.Contracts.Period)
if expectedCollateral.Cmp(pt.MaxCollateral) > 0 {
expectedCollateral = pt.MaxCollateral
}

// avoid division by zero
if expectedCollateral.IsZero() {
expectedCollateral = types.NewCurrency64(1)
}

// determine a cutoff at 20% of the budgeted per-host funds.
// Meaning that an 'ok' host puts in 1/5 of what the renter puts into a
// contract. Beyond that the score increases linearly and below that
// decreases exponentially.
cutoff := hostCostPerPeriod.Div64(5)
// determine a cutoff at 150% of the storage cost. Meaning that a host
// should be willing to put in at least 1.5x the amount of money the renter
// expects to spend on storage on that host.
cutoff := storageCost.Mul64(ratioNum).Div64(ratioDenom)

// calculate the weight. We use the same approach here as in
// priceAdjustScore but with a different cutoff.
ratio := new(big.Rat).SetFrac(cutoff.Big(), expectedCollateral.Big())
fRatio, _ := ratio.Float64()
switch ratio.Cmp(new(big.Rat).SetUint64(1)) {
case 0:
return 0.5 // ratio is exactly 1 -> score is 0.5
case 1:
// collateral is below cutoff -> score is in range (0; 0.5)
//
return 1.5 / math.Pow(3, fRatio)
case -1:
// collateral is beyond cutoff -> score is (0.5; 1]
s := 0.5 * (1 / fRatio)
if s > 1.0 {
s = 1.0
// the score is a linear function between 0 and 1 where the upper limit is
// 4 times the cutoff. Beyond that, we don't care if a host puts in more
// collateral.
cutoffMultiplier := uint64(4)

if expectedCollateral.Cmp(cutoff) < 0 {
return 0 // expectedCollateral <= cutoff -> score is 0
} else if expectedCollateral.Cmp(cutoff.Mul64(cutoffMultiplier)) >= 0 {
return 1 // expectedCollateral is 10x cutoff -> score is 1
} else {
// Perform linear interpolation for all other values.
slope := new(big.Rat).SetFrac(new(big.Int).SetInt64(1), cutoff.Mul64(cutoffMultiplier).Big())
intercept := new(big.Rat).Mul(slope, new(big.Rat).SetInt(cutoff.Big())).Neg(slope)
score := new(big.Rat).SetInt(expectedCollateral.Big())
score = score.Mul(score, slope)
score = score.Add(score, intercept)
fScore, _ := score.Float64()
if fScore > 1 {
return 1.0
}
return s
return fScore
}
panic("unreachable")
}

func interactionScore(h hostdb.Host) float64 {
Expand Down
86 changes: 46 additions & 40 deletions autopilot/hostscore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"time"

rhpv2 "go.sia.tech/core/rhp/v2"
rhpv3 "go.sia.tech/core/rhp/v3"
"go.sia.tech/core/types"
"go.sia.tech/renterd/api"
"go.sia.tech/renterd/hostdb"
Expand Down Expand Up @@ -93,7 +94,7 @@ func TestHostScore(t *testing.T) {

// assert MaxCollateral affects the score.
h2 = newHost(newTestHostSettings()) // reset
h2.Settings.MaxCollateral = types.ZeroCurrency
h2.PriceTable.MaxCollateral = types.ZeroCurrency
if hostScore(cfg, h1, 0, redundancy).Score() <= hostScore(cfg, h2, 0, redundancy).Score() {
t.Fatal("unexpected")
}
Expand Down Expand Up @@ -170,64 +171,69 @@ func TestPriceAdjustmentScore(t *testing.T) {
}

func TestCollateralScore(t *testing.T) {
period := uint64(5)
storageCost := uint64(100)
score := func(collateral, maxCollateral uint64) float64 {
t.Helper()
cfg := api.AutopilotConfig{
Contracts: api.ContractsConfig{
Period: 5,
Storage: 5,
Period: period,
},
}
settings := rhpv2.HostSettings{
Collateral: types.NewCurrency64(collateral),
MaxCollateral: types.NewCurrency64(maxCollateral),
pt := rhpv3.HostPriceTable{
CollateralCost: types.NewCurrency64(collateral),
MaxCollateral: types.NewCurrency64(maxCollateral),
WriteStoreCost: types.NewCurrency64(storageCost),
}
return collateralScore(cfg, types.NewCurrency64(5000), settings, 2.0)
return collateralScore(cfg, pt, rhpv2.SectorSize)
}

// NOTE: with the above settings, the cutoff is at 1000H.

// Cost matches cutoff since MaxCollateral is 2000 and we divide by 2 for a
// buffer.
if s := score(math.MaxInt64, 2000); s != 0.5 {
t.Errorf("expected %v but got %v", 0.5, s)
}
if s := score(20, math.MaxInt64); s != 0.5 {
t.Errorf("expected %v but got %v", 0.5, s)
round := func(f float64) float64 {
i := uint64(f * 100.0)
return float64(i) / 100.0
}

// Increase collateral. Score should approach 1.
if s := score(21, math.MaxInt64); s != 0.525 {
t.Errorf("expected %v but got %v", 0.525, s)
}
if s := score(25, math.MaxInt64); s != 0.625 {
t.Errorf("expected %v but got %v", 0.625, s)
}
if s := score(35, math.MaxInt64); s != 0.875 {
t.Errorf("expected %v but got %v", 0.875, s)
// NOTE: with the above settings, the cutoff is at 7500H.
cutoff := uint64(storageCost * rhpv2.SectorSize * period * 3 / 2)
cutoffCollateral := storageCost * 3 / 2

// Collateral is exactly at cutoff.
if s := round(score(math.MaxInt64, cutoff)); s != 0.24 {
t.Errorf("expected %v but got %v", 0.24, s)
}
if s := score(50, math.MaxInt64); s != 1 {
t.Errorf("expected %v but got %v", 1, s)
if s := round(score(cutoffCollateral, math.MaxInt64)); s != 0.24 {
t.Errorf("expected %v but got %v", 0.24, s)
}

// Decrease collateral. Score should approach 0.
round := func(f float64) float64 {
i := uint64(f * 100.0)
return float64(i) / 100.0
// Increase collateral with linear steps. Score should approach linearly as
// well.
// 1.5 times cutoff
if s := round(score(cutoffCollateral*3/2, math.MaxInt64)); s != 0.37 {
t.Errorf("expected %v but got %v", 0.37, s)
}
if s := round(score(19, math.MaxInt64)); s != 0.47 {
t.Errorf("expected %v but got %v", 0.47, s)
// 2 times cutoff
if s := round(score(2*cutoffCollateral, math.MaxInt64)); s != 0.49 {
t.Errorf("expected %v but got %v", 0.49, s)
}
if s := round(score(15, math.MaxInt64)); s != 0.34 {
t.Errorf("expected %v but got %v", 0.34, s)
// 2.5 times cutoff
if s := round(score(cutoffCollateral*5/2, math.MaxInt64)); s != 0.62 {
t.Errorf("expected %v but got %v", 0.62, s)
}
if s := round(score(10, math.MaxInt64)); s != 0.16 {
t.Errorf("expected %v but got %v", 0.16, s)
// 3 times cutoff
if s := round(score(3*cutoffCollateral, math.MaxInt64)); s != 0.74 {
t.Errorf("expected %v but got %v", 0.74, s)
}
if s := round(score(5, math.MaxInt64)); s != 0.01 {
t.Errorf("expected %v but got %v", 0.01, s)
// 3.5 times cutoff
if s := round(score(cutoffCollateral*7/2, math.MaxInt64)); s != 0.87 {
t.Errorf("expected %v but got %v", 0.87, s)
}
if s := round(score(1, math.MaxInt64)); s != 00 {
// 4 times cutoff
if s := round(score(4*cutoffCollateral, math.MaxInt64)); s != 1 {
t.Errorf("expected %v but got %v", 1, s)
}

// Going below the cutoff should result in a score of 0.
if s := round(score(cutoffCollateral-1, math.MaxInt64)); s != 0 {
t.Errorf("expected %v but got %v", 0, s)
}
}
Expand Down

0 comments on commit a2cd963

Please sign in to comment.