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 }