github.com/lino-network/lino@v0.6.11/x/reputation/repv2/reputation.go (about)

     1  package repv2
     2  
     3  import (
     4  	"fmt"
     5  	"io/ioutil"
     6  	"os"
     7  	"strconv"
     8  )
     9  
    10  // This package is not thread-safe.
    11  
    12  type Reputation interface {
    13  	// Donate to post @p p wit @p s coins.
    14  	// Note that if migrate is required, it must be done before donate.
    15  	DonateAt(u Uid, p Pid, s LinoCoin) IF
    16  
    17  	// user's freescore += @p r, NOTE: unit is COIN.
    18  	IncFreeScore(u Uid, r Rep)
    19  
    20  	// needs to be called every endblocker.
    21  	Update(t Time)
    22  
    23  	// current reputation of the user.
    24  	GetReputation(u Uid) Rep
    25  
    26  	// Round 0 is an invalidated round
    27  	// Round 1 is a short round that will last for only one block, because round-1's
    28  	// start time is set to 0.
    29  	GetCurrentRound() (RoundId, Time) // current round and its start time.
    30  
    31  	// ExportImporter
    32  	ExportToFile(file string) error
    33  	ImportFromFile(file string) error
    34  }
    35  
    36  type ReputationImpl struct {
    37  	store                ReputationStore
    38  	BestN                int
    39  	UserMaxN             int
    40  	RoundDurationSeconds int64
    41  	SampleWindowSize     int64
    42  	DecayFactor          int64
    43  }
    44  
    45  var _ Reputation = ReputationImpl{}
    46  
    47  func NewReputation(s ReputationStore, bestN int, userMaxN int, roundDurationSeconds, sampleWindowSize, decayFactor int64) Reputation {
    48  	return ReputationImpl{
    49  		store:                s,
    50  		BestN:                bestN,
    51  		UserMaxN:             userMaxN,
    52  		RoundDurationSeconds: roundDurationSeconds,
    53  		SampleWindowSize:     sampleWindowSize,
    54  		DecayFactor:          decayFactor,
    55  	}
    56  }
    57  
    58  // ExportToFile - implementing ExporteImporter
    59  func (rep ReputationImpl) ExportToFile(file string) error {
    60  	// before calling store's export, update reputation.
    61  	rep.store.IterateUsers(func(u Uid) bool {
    62  		rep.GetReputation(u)
    63  		return false
    64  	})
    65  	rst := rep.store.Export()
    66  	f, err := os.Create(file)
    67  	if err != nil {
    68  		return fmt.Errorf("failed to create file: %s", err)
    69  	}
    70  	defer f.Close()
    71  	jsonbytes, err := cdc.MarshalJSON(rst)
    72  	if err != nil {
    73  		return fmt.Errorf("failed to marshal json for " + file + " due to " + err.Error())
    74  	}
    75  	_, err = f.Write(jsonbytes)
    76  	if err != nil {
    77  		return err
    78  	}
    79  	err = f.Sync()
    80  	if err != nil {
    81  		return err
    82  	}
    83  	return nil
    84  }
    85  
    86  // ImportFromFile - implementing ExporteImporter
    87  func (rep ReputationImpl) ImportFromFile(file string) error {
    88  	f, err := os.Open(file)
    89  	if err != nil {
    90  		return fmt.Errorf("failed to open " + err.Error())
    91  	}
    92  	defer f.Close()
    93  	bytes, err := ioutil.ReadAll(f)
    94  	if err != nil {
    95  		return fmt.Errorf("failed to readall: " + err.Error())
    96  	}
    97  	dt := &UserReputationTable{}
    98  	err = cdc.UnmarshalJSON(bytes, dt)
    99  	if err != nil {
   100  		return fmt.Errorf("failed to unmarshal: " + err.Error())
   101  	}
   102  	rep.store.Import(dt)
   103  	return nil
   104  }
   105  
   106  // internal struct as a summary of a user's consumption in a round.
   107  type consumptionInfo struct {
   108  	seed    LinoCoin
   109  	other   LinoCoin
   110  	seedIF  IF
   111  	otherIF IF
   112  }
   113  
   114  // extract user's consumptionInfo from userMeta. The @p seedSet is the result
   115  // of the user's the last donation, and also the round when donations in user.Unsettled
   116  // happened.
   117  func (rep ReputationImpl) extractConsumptionInfo(user *userMeta, seedSet map[Pid]bool) consumptionInfo {
   118  	seed := NewInt(0)
   119  	other := NewInt(0)
   120  	seedIF := NewInt(0)
   121  	otherIF := NewInt(0)
   122  	for _, pd := range user.Unsettled {
   123  		amount := pd.Amount
   124  		pid := pd.Pid
   125  		impact := pd.Impact
   126  		if _, isTop := seedSet[pid]; isTop {
   127  			seed.Add(amount)
   128  			seedIF.Add(impact)
   129  		} else {
   130  			other.Add(amount)
   131  			otherIF.Add(impact)
   132  		}
   133  	}
   134  	return consumptionInfo{
   135  		seed:    seed,
   136  		other:   other,
   137  		seedIF:  seedIF,
   138  		otherIF: otherIF,
   139  	}
   140  }
   141  
   142  // return the seed set of the @p id round.
   143  func (rep ReputationImpl) getSeedSet(id RoundId) map[Pid]bool {
   144  	result := rep.store.GetRoundMeta(id)
   145  	tops := make(map[Pid]bool)
   146  	for _, p := range result.Result {
   147  		tops[p] = true
   148  	}
   149  	return tops
   150  }
   151  
   152  // compute reputation from @p consumption and @p hold.
   153  func (rep ReputationImpl) computeReputation(consumption LinoCoin, hold Rep) Rep {
   154  	return IntMax(
   155  		IntSub(consumption, IntMul(hold, NewInt(rep.SampleWindowSize))),
   156  		NewInt(0),
   157  	)
   158  }
   159  
   160  // internal struct for reputation data of a user.
   161  type reputationData struct {
   162  	consumption LinoCoin
   163  	hold        Rep
   164  	reputation  Rep
   165  }
   166  
   167  // will mutate @p user to new reputation score based on @p consumptions
   168  // Return a new reputationData that is computed from (a user's) @p repData with
   169  // new @p consumptions in a round.
   170  func (rep ReputationImpl) computeNewRepData(repData reputationData, consumptions consumptionInfo) reputationData {
   171  	seed := consumptions.seed
   172  	other := consumptions.other
   173  	seedIF := consumptions.seedIF
   174  	otherIF := consumptions.otherIF
   175  
   176  	adjustedConsumption := IntMin(
   177  		IntMax(IntDivFrac(seedIF, 8, 10), seed),
   178  		IntAdd(seed, other),
   179  	)
   180  	newConsumption := repData.consumption
   181  	if IntGreater(adjustedConsumption, repData.consumption) {
   182  		newConsumption = IntEMA(repData.consumption, adjustedConsumption, rep.SampleWindowSize)
   183  	}
   184  	otherLimit := IntDiv(IntAdd(seedIF, otherIF), NewInt(5)) // * 20%
   185  	if IntGreater(otherIF, otherLimit) {
   186  		newConsumption = IntSub(newConsumption,
   187  			IntMax(NewInt(1),
   188  				IntMulFrac(IntSub(otherIF, otherLimit), rep.DecayFactor, 100)))
   189  		newConsumption = IntMax(NewInt(0), newConsumption)
   190  	}
   191  
   192  	if IntGTE(newConsumption, repData.consumption) {
   193  		delta := IntSub(newConsumption, repData.consumption)
   194  		repData.hold = IntEMA(repData.hold, delta, rep.SampleWindowSize)
   195  	}
   196  
   197  	repData.consumption = newConsumption
   198  	repData.reputation = rep.computeReputation(repData.consumption, repData.hold)
   199  	return repData
   200  }
   201  
   202  // update @p user with information of @p current round.
   203  func (rep ReputationImpl) updateReputation(user *userMeta, current RoundId) {
   204  	// needs to update user's reputation only when the last settled
   205  	// round is less than the last donation round, *and* current round
   206  	// is newer than last donation round(i.e. the last donation round has ended).
   207  	// if above conditions do not hold, skip update.
   208  	if !(user.LastSettledRound < user.LastDonationRound && user.LastDonationRound < current) {
   209  		return
   210  	}
   211  
   212  	seedset := rep.getSeedSet(user.LastDonationRound)
   213  	consumptions := rep.extractConsumptionInfo(user, seedset)
   214  	newrep := rep.computeNewRepData(reputationData{
   215  		consumption: user.Consumption,
   216  		hold:        user.Hold,
   217  		reputation:  user.Reputation,
   218  	}, consumptions)
   219  
   220  	// remove roundPostSumImpacts that are not no longer used.
   221  	// Note: this does not guarantee that all keys will be deleted, but rather
   222  	// set a bound on how much data one user may hold.
   223  	for _, pd := range user.Unsettled {
   224  		rep.deleteRoumdPostSumImpact(user.LastDonationRound, pd.Pid)
   225  	}
   226  
   227  	user.Consumption = newrep.consumption
   228  	user.Hold = newrep.hold
   229  	user.Reputation = newrep.reputation
   230  	user.LastSettledRound = user.LastDonationRound
   231  	user.Unsettled = nil
   232  }
   233  
   234  // return the reputation of @p u.
   235  func (rep ReputationImpl) GetReputation(u Uid) Rep {
   236  	user := rep.store.GetUserMeta(u)
   237  	defer func() {
   238  		rep.store.SetUserMeta(u, user)
   239  	}()
   240  	current := rep.store.GetCurrentRound()
   241  	rep.updateReputation(user, current)
   242  	return user.Reputation
   243  }
   244  
   245  // Record @p u has donated to @p p with @p amount LinoCoin.
   246  // Only the first UserMaxN posts will be counted and have impact.
   247  // The invarience is that user will have only *one* round of donations unsettled,
   248  // either because that round is current round(not ended yet),
   249  // or the user has never donated after that round.
   250  // So when a user donates, we first update user's reputation, then
   251  // we add this donation to records.
   252  func (rep ReputationImpl) DonateAt(u Uid, p Pid, amount LinoCoin) IF {
   253  	if len(u) == 0 {
   254  		panic("Length of Uid must be longer than 0")
   255  	}
   256  	if len(p) == 0 {
   257  		panic("Length of Pid must be longer than 0")
   258  	}
   259  	var current RoundId = rep.store.GetCurrentRound()
   260  	user := rep.store.GetUserMeta(u)
   261  	defer func() {
   262  		rep.store.SetUserMeta(u, user)
   263  	}()
   264  	rep.updateReputation(user, current)
   265  	user.LastDonationRound = current
   266  	impact := rep.appendDonation(user, p, amount)
   267  	rep.incRoundPostSumImpact(current, p, impact)
   268  	return impact
   269  }
   270  
   271  // appendDonation: append a new donation to user's unsettled list, return the impact
   272  // factor of this donation.
   273  // contract: before calling this, user's reputation needs to
   274  //           be updated by calling updateReputation.
   275  func (rep ReputationImpl) appendDonation(user *userMeta, post Pid, amount LinoCoin) IF {
   276  	reputation := user.Reputation
   277  	pos := -1
   278  	used := NewInt(0)
   279  	for i, v := range user.Unsettled {
   280  		used.Add(v.Impact)
   281  		if v.Pid == post {
   282  			pos = i
   283  		}
   284  	}
   285  	if pos == -1 && len(user.Unsettled) >= rep.UserMaxN {
   286  		return NewInt(0)
   287  	}
   288  	var available IF = IntMax(IntSub(reputation, used), NewInt(0))
   289  	var impact IF = IntMin(available, amount)
   290  	if pos != -1 {
   291  		user.Unsettled[pos].Amount.Add(amount)
   292  		user.Unsettled[pos].Impact.Add(impact)
   293  	} else {
   294  		user.Unsettled = append(user.Unsettled, Donation{
   295  			Pid:    post,
   296  			Amount: amount,
   297  			Impact: impact,
   298  		})
   299  	}
   300  	return impact
   301  }
   302  
   303  func (rep ReputationImpl) deleteRoumdPostSumImpact(round RoundId, p Pid) {
   304  	rep.store.DelRoundPostMeta(round, p)
   305  }
   306  
   307  // increase the sum of impact factors of @p post by @p dp, in @p round
   308  // It also maintains an order of posts of the round by bubbling up the rank
   309  // of post on impact factor increasing.
   310  func (rep ReputationImpl) incRoundPostSumImpact(round RoundId, p Pid, dp IF) {
   311  	roundPost := rep.store.GetRoundPostMeta(round, p)
   312  	defer func() {
   313  		rep.store.SetRoundPostMeta(round, p, roundPost)
   314  	}()
   315  	roundMeta := rep.store.GetRoundMeta(round)
   316  	defer func() {
   317  		rep.store.SetRoundMeta(round, roundMeta)
   318  	}()
   319  
   320  	roundMeta.SumIF.Add(dp)
   321  	newSumIF := IntAdd(roundPost.SumIF, dp)
   322  	roundPost.SumIF = newSumIF
   323  
   324  	pos := -1
   325  	for i, v := range roundMeta.TopN {
   326  		if v.Pid == p {
   327  			roundMeta.TopN[i].SumIF = newSumIF
   328  			pos = i
   329  			break
   330  		}
   331  	}
   332  	if pos == -1 {
   333  		// first post.
   334  		if len(roundMeta.TopN) < rep.BestN {
   335  			roundMeta.TopN = append(roundMeta.TopN, PostIFPair{Pid: p, SumIF: newSumIF})
   336  		} else {
   337  			lastSumIF := roundMeta.TopN[len(roundMeta.TopN)-1].SumIF
   338  			// lastSumIF < newSumIF
   339  			if IntLess(lastSumIF, newSumIF) {
   340  				roundMeta.TopN = append(roundMeta.TopN, PostIFPair{Pid: p, SumIF: newSumIF})
   341  			} else {
   342  				// do not need to do anything, as this post's sumDP
   343  				// is less or equal to the last one.
   344  				return
   345  			}
   346  		}
   347  		pos = len(roundMeta.TopN) - 1
   348  	}
   349  	bubbleUp(roundMeta.TopN, pos)
   350  	// keeping bestN only.
   351  	if len(roundMeta.TopN) > rep.BestN {
   352  		roundMeta.TopN = roundMeta.TopN[:rep.BestN]
   353  	}
   354  }
   355  
   356  // return the current round id the the start time of the round.
   357  func (rep ReputationImpl) GetCurrentRound() (RoundId, Time) {
   358  	rid := rep.store.GetCurrentRound()
   359  	return rid, rep.store.GetRoundMeta(rid).StartAt
   360  }
   361  
   362  // increase @p u user's reputation by @p score.
   363  // To make added score permanent, add it on consumption, as reputation is
   364  // only a temporory result, same in reputation migration.
   365  func (rep ReputationImpl) IncFreeScore(u Uid, score Rep) {
   366  	user := rep.store.GetUserMeta(u)
   367  	defer func() {
   368  		rep.store.SetUserMeta(u, user)
   369  	}()
   370  	user.Consumption.Add(score)
   371  	user.Consumption = IntMax(user.Consumption, NewInt(0))
   372  	user.Reputation = rep.computeReputation(user.Consumption, user.Hold)
   373  }
   374  
   375  // On BlockEnd(@p t), select out the seed set of the current round and start
   376  // a new round.
   377  func (rep ReputationImpl) Update(t Time) {
   378  	current := rep.store.GetCurrentRound()
   379  	roundInfo := rep.store.GetRoundMeta(current)
   380  	if int64(t-roundInfo.StartAt) >= rep.RoundDurationSeconds {
   381  		// need to update only when it is updated.
   382  		defer func() {
   383  			rep.store.SetRoundMeta(current, roundInfo)
   384  			// start a new round
   385  			rep.StartNewRound(t)
   386  		}()
   387  		// process all information of this round
   388  		// Find out top N.
   389  		topN := roundInfo.TopN
   390  		sumDpInRound := roundInfo.SumIF
   391  		// XXX(yumin): taking a floor of 80%.
   392  		dpBound := IntMulFrac(sumDpInRound, 8, 10)
   393  		dpCovered := NewInt(0)
   394  		var rst []Pid
   395  		for _, pidDp := range topN {
   396  			pid := pidDp.Pid
   397  			postIF := pidDp.SumIF
   398  			if !IntGreater(postIF, NewInt(0)) {
   399  				break
   400  			}
   401  			dpCovered.Add(postIF)
   402  			rst = append(rst, pid)
   403  			if !IntLess(dpCovered, dpBound) {
   404  				break
   405  			}
   406  		}
   407  		roundInfo.Result = rst
   408  	}
   409  }
   410  
   411  // write (RoundId + 1, t) into db and update current round
   412  func (rep ReputationImpl) StartNewRound(t Time) {
   413  	gameMeta := rep.store.GetGameMeta()
   414  	defer func() {
   415  		rep.store.SetGameMeta(gameMeta)
   416  	}()
   417  	newRoundId := gameMeta.CurrentRound + 1
   418  	gameMeta.CurrentRound = newRoundId
   419  
   420  	newRoundMeta := &roundMeta{
   421  		Result:  nil,
   422  		SumIF:   NewInt(0),
   423  		StartAt: t,
   424  		TopN:    nil,
   425  	}
   426  	rep.store.SetRoundMeta(newRoundId, newRoundMeta)
   427  }
   428  
   429  // return the exponential moving average of @p prev on having a new sample @p new
   430  // with sample size of @p windowSize.
   431  func IntEMA(prev, new Int, windowSize int64) Int {
   432  	if windowSize <= 0 {
   433  		panic("IntEMA illegal windowSize: " + strconv.FormatInt(windowSize, 10))
   434  	}
   435  	return IntDiv(
   436  		IntAdd(new, IntMul(prev, NewInt(windowSize-1))),
   437  		NewInt(windowSize))
   438  }
   439  
   440  // contract:
   441  //     before: all inversions are related to posts[pos].
   442  //     after:  posts are sorted by SumIF, decreasingly.
   443  func bubbleUp(posts []PostIFPair, pos int) {
   444  	for i := pos; i > 0; i-- {
   445  		if IntLess(posts[i-1].SumIF, posts[i].SumIF) {
   446  			posts[i], posts[i-1] = posts[i-1], posts[i]
   447  		} else {
   448  			break
   449  		}
   450  	}
   451  }