diff --git a/autopilot/host_test.go b/autopilot/host_test.go index 0d1872cdc..fa1a0ab44 100644 --- a/autopilot/host_test.go +++ b/autopilot/host_test.go @@ -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 diff --git a/autopilot/hostscore.go b/autopilot/hostscore.go index 13d58ab18..f0f103c6c 100644 --- a/autopilot/hostscore.go +++ b/autopilot/hostscore.go @@ -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), } @@ -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. @@ -122,21 +123,24 @@ 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 @@ -144,32 +148,33 @@ func collateralScore(cfg api.AutopilotConfig, hostCostPerPeriod types.Currency, 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 { diff --git a/autopilot/hostscore_test.go b/autopilot/hostscore_test.go index e9ce862b9..c352f9339 100644 --- a/autopilot/hostscore_test.go +++ b/autopilot/hostscore_test.go @@ -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" @@ -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") } @@ -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) } }