Coverage for integrations / social / resonance_engine.py: 60.2%

191 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-12 04:49 +0000

1""" 

2HevolveSocial - Resonance Engine 

3Multi-dimensional reward system: Pulse, Spark, Signal, XP. 

4""" 

5import logging 

6import math 

7from datetime import datetime, timedelta 

8from typing import Optional, Dict, List, Tuple 

9 

10from sqlalchemy import desc, func 

11from sqlalchemy.orm import Session 

12 

13from .models import ( 

14 User, ResonanceWallet, ResonanceTransaction 

15) 

16 

17logger = logging.getLogger('hevolve_social') 

18 

19# ─── Level/Title System ─── 

20 

21LEVEL_TITLES = { 

22 1: 'Newcomer', 3: 'Contributor', 5: 'Regular', 8: 'Established', 

23 10: 'Veteran', 15: 'Expert', 20: 'Master', 25: 'Luminary', 

24 30: 'Legend', 40: 'Architect', 50: 'Founding Pillar', 

25} 

26 

27 

28def xp_for_level(n: int) -> int: 

29 return int(100 * (1.15 ** (n - 1))) 

30 

31 

32def title_for_level(level: int) -> str: 

33 title = 'Newcomer' 

34 for threshold, t in sorted(LEVEL_TITLES.items()): 

35 if level >= threshold: 

36 title = t 

37 return title 

38 

39 

40# ─── Award Tables ─── 

41 

42AWARD_TABLE = { 

43 'post_upvote': {'pulse': 1, 'xp': 2}, 

44 'create_post': {'spark': 5, 'signal': 0.01, 'xp': 10}, 

45 'create_comment': {'spark': 2, 'signal': 0.005, 'xp': 5}, 

46 'complete_task': {'pulse': 10, 'spark': 20, 'signal': 0.1, 'xp': 50}, 

47 'recipe_shared': {'spark': 15, 'signal': 0.05, 'xp': 25}, 

48 'recipe_forked': {'pulse': 5, 'spark': 10, 'signal': 0.02, 'xp': 15}, 

49 'referral_signup': {'pulse': 5, 'spark': 15, 'signal': 0.02, 'xp': 25}, 

50 'referral_activated': {'pulse': 20, 'spark': 50, 'signal': 0.1, 'xp': 100}, 

51 'correct_moderation': {'signal': 0.05, 'xp': 10}, 

52 'campaign_milestone': {'pulse': 5, 'spark': 25, 'signal': 0.03, 'xp': 30}, 

53 'ad_impression_served': {'spark': 1}, 

54 'hosting_uptime_bonus': {'spark': 10, 'pulse': 5, 'xp': 20}, 

55 'hosting_milestone': {'spark': 50, 'pulse': 25, 'xp': 100}, 

56 'learning_contribution': {'spark': 25, 'pulse': 10, 'signal': 0.08, 'xp': 40}, 

57 'learning_skill_shared': {'spark': 15, 'signal': 0.05, 'xp': 25}, 

58 'learning_credit_assigned': {'spark': 5, 'xp': 10}, 

59 'experiment_proposed': {'spark': 20, 'pulse': 10, 'signal': 0.10, 'xp': 30}, 

60 'experiment_voted': {'spark': 10, 'pulse': 5, 'xp': 15}, 

61 'experiment_evaluated': {'spark': 30, 'signal': 0.08, 'xp': 50}, 

62 'experiment_suggestion': {'spark': 15, 'signal': 0.05, 'xp': 20}, 

63 # Multiplayer games 

64 'game_win': {'pulse': 15, 'spark': 10, 'xp': 30}, 

65 'game_participate': {'pulse': 5, 'spark': 5, 'xp': 15}, 

66 'game_streak_3': {'pulse': 10, 'spark': 15, 'xp': 25}, 

67 'multiplayer_encounter': {'pulse': 5, 'xp': 10}, 

68 # Compute lending 

69 'compute_opt_in': {'pulse': 25, 'spark': 50, 'xp': 50}, 

70 'compute_hour': {'spark': 10, 'xp': 20}, 

71 'compute_day_streak': {'spark': 25, 'pulse': 10, 'xp': 40}, 

72 'experiment_pledge': {'pulse': 20, 'spark': 30, 'signal': 0.10, 'xp': 40}, 

73 # Content sharing 

74 'content_shared': {'spark': 5, 'xp': 10}, 

75 'content_viral_10': {'spark': 25, 'xp': 20}, 

76 'content_viral_50': {'spark': 100, 'xp': 50}, 

77} 

78 

79 

80class ResonanceService: 

81 

82 @staticmethod 

83 def get_or_create_wallet(db: Session, user_id: str) -> ResonanceWallet: 

84 wallet = db.query(ResonanceWallet).filter_by(user_id=user_id).first() 

85 if not wallet: 

86 wallet = ResonanceWallet(user_id=user_id) 

87 db.add(wallet) 

88 db.flush() 

89 return wallet 

90 

91 @staticmethod 

92 def get_wallet(db: Session, user_id: str) -> Optional[Dict]: 

93 wallet = db.query(ResonanceWallet).filter_by(user_id=user_id).first() 

94 if wallet: 

95 return wallet.to_dict() 

96 return None 

97 

98 @staticmethod 

99 def _log_transaction(db: Session, user_id: str, currency: str, 

100 amount: float, balance_after: float, 

101 source_type: str, source_id: str = None, 

102 description: str = ''): 

103 txn = ResonanceTransaction( 

104 user_id=user_id, currency=currency, 

105 amount=amount, balance_after=balance_after, 

106 source_type=source_type, source_id=source_id, 

107 description=description, 

108 ) 

109 db.add(txn) 

110 

111 @staticmethod 

112 def award_pulse(db: Session, user_id: str, amount: int, 

113 source_type: str, source_id: str = None, 

114 description: str = '') -> int: 

115 wallet = ResonanceService.get_or_create_wallet(db, user_id) 

116 wallet.pulse += amount 

117 wallet.season_pulse += amount 

118 ResonanceService._log_transaction( 

119 db, user_id, 'pulse', amount, wallet.pulse, 

120 source_type, source_id, description) 

121 return wallet.pulse 

122 

123 @staticmethod 

124 def award_spark(db: Session, user_id: str, amount: int, 

125 source_type: str, source_id: str = None, 

126 description: str = '') -> int: 

127 wallet = ResonanceService.get_or_create_wallet(db, user_id) 

128 wallet.spark += amount 

129 wallet.spark_lifetime += amount 

130 wallet.season_spark += amount 

131 ResonanceService._log_transaction( 

132 db, user_id, 'spark', amount, wallet.spark, 

133 source_type, source_id, description) 

134 return wallet.spark 

135 

136 @staticmethod 

137 def spend_spark(db: Session, user_id: str, amount: int, 

138 source_type: str, source_id: str = None, 

139 description: str = '') -> Tuple[bool, int]: 

140 wallet = ResonanceService.get_or_create_wallet(db, user_id) 

141 if wallet.spark < amount: 

142 return False, wallet.spark 

143 wallet.spark -= amount 

144 ResonanceService._log_transaction( 

145 db, user_id, 'spark', -amount, wallet.spark, 

146 source_type, source_id, description) 

147 return True, wallet.spark 

148 

149 @staticmethod 

150 def award_signal(db: Session, user_id: str, amount: float, 

151 source_type: str, source_id: str = None, 

152 description: str = '') -> float: 

153 wallet = ResonanceService.get_or_create_wallet(db, user_id) 

154 wallet.signal += amount 

155 wallet.signal_last_decay = datetime.utcnow() 

156 ResonanceService._log_transaction( 

157 db, user_id, 'signal', amount, wallet.signal, 

158 source_type, source_id, description) 

159 return wallet.signal 

160 

161 @staticmethod 

162 def award_xp(db: Session, user_id: str, amount: int, 

163 source_type: str, source_id: str = None, 

164 description: str = '') -> Dict: 

165 wallet = ResonanceService.get_or_create_wallet(db, user_id) 

166 wallet.xp += amount 

167 ResonanceService._log_transaction( 

168 db, user_id, 'xp', amount, wallet.xp, 

169 source_type, source_id, description) 

170 leveled_up = ResonanceService._check_level_up(db, wallet) 

171 return { 

172 'xp': wallet.xp, 'level': wallet.level, 

173 'level_title': wallet.level_title, 

174 'leveled_up': leveled_up, 

175 } 

176 

177 @staticmethod 

178 def _check_level_up(db: Session, wallet: ResonanceWallet) -> bool: 

179 leveled = False 

180 while wallet.xp >= wallet.xp_next_level: 

181 wallet.xp -= wallet.xp_next_level 

182 wallet.level += 1 

183 wallet.level_title = title_for_level(wallet.level) 

184 wallet.xp_next_level = xp_for_level(wallet.level + 1) 

185 leveled = True 

186 if leveled: 

187 # Sync level to user table 

188 user = db.query(User).filter_by(id=wallet.user_id).first() 

189 if user: 

190 user.level = wallet.level 

191 user.level_title = wallet.level_title 

192 return leveled 

193 

194 @staticmethod 

195 def award_action(db: Session, user_id: str, action: str, 

196 source_id: str = None) -> Dict: 

197 """Award all currencies for a known action type.""" 

198 awards = AWARD_TABLE.get(action, {}) 

199 if not awards: 

200 return {} 

201 

202 result = {} 

203 if 'pulse' in awards: 

204 result['pulse'] = ResonanceService.award_pulse( 

205 db, user_id, awards['pulse'], action, source_id, 

206 f'Earned for {action}') 

207 if 'spark' in awards: 

208 result['spark'] = ResonanceService.award_spark( 

209 db, user_id, awards['spark'], action, source_id, 

210 f'Earned for {action}') 

211 if 'signal' in awards: 

212 result['signal'] = ResonanceService.award_signal( 

213 db, user_id, awards['signal'], action, source_id, 

214 f'Earned for {action}') 

215 if 'xp' in awards: 

216 xp_result = ResonanceService.award_xp( 

217 db, user_id, awards['xp'], action, source_id, 

218 f'Earned for {action}') 

219 result.update(xp_result) 

220 

221 return result 

222 

223 @staticmethod 

224 def award(db: Session, user_id: str, action: str, amount: int, 

225 reason: str = '') -> Dict: 

226 """Generic award entry point for action-based rewards. 

227 

228 Looks up *action* in AWARD_TABLE. When found the table values 

229 are used (``amount`` is ignored); otherwise ``amount`` spark and 

230 ``amount // 2`` xp are awarded as a sensible default. 

231 """ 

232 awards = AWARD_TABLE.get(action) 

233 if awards: 

234 spark = awards.get('spark', 0) 

235 xp = awards.get('xp', 0) 

236 else: 

237 spark = amount 

238 xp = amount // 2 

239 

240 result = {} 

241 desc = reason or f'Earned for {action}' 

242 if spark: 

243 result['spark'] = ResonanceService.award_spark( 

244 db, user_id, spark, action, None, desc) 

245 if xp: 

246 xp_result = ResonanceService.award_xp( 

247 db, user_id, xp, action, None, desc) 

248 result.update(xp_result) 

249 return result 

250 

251 @staticmethod 

252 def process_streak(db: Session, user_id: str) -> Dict: 

253 """Process daily login streak. Call once per day per user.""" 

254 wallet = ResonanceService.get_or_create_wallet(db, user_id) 

255 today = datetime.utcnow().strftime('%Y-%m-%d') 

256 

257 if wallet.last_active_date == today: 

258 return {'streak_days': wallet.streak_days, 'already_checked_in': True} 

259 

260 yesterday = (datetime.utcnow() - timedelta(days=1)).strftime('%Y-%m-%d') 

261 if wallet.last_active_date == yesterday: 

262 wallet.streak_days += 1 

263 else: 

264 wallet.streak_days = 1 

265 

266 wallet.last_active_date = today 

267 if wallet.streak_days > wallet.streak_best: 

268 wallet.streak_best = wallet.streak_days 

269 

270 # Award streak spark: +streak_day 

271 spark_bonus = wallet.streak_days 

272 ResonanceService.award_spark( 

273 db, user_id, spark_bonus, 'streak', None, 

274 f'Day {wallet.streak_days} streak bonus') 

275 

276 # Award streak XP: +5 * streak_day 

277 xp_bonus = 5 * wallet.streak_days 

278 ResonanceService.award_xp( 

279 db, user_id, xp_bonus, 'streak', None, 

280 f'Day {wallet.streak_days} streak XP') 

281 

282 return { 

283 'streak_days': wallet.streak_days, 

284 'streak_best': wallet.streak_best, 

285 'spark_bonus': spark_bonus, 

286 'xp_bonus': xp_bonus, 

287 'already_checked_in': False, 

288 } 

289 

290 @staticmethod 

291 def apply_signal_decay(db: Session) -> int: 

292 """Batch job: apply 0.2%/day signal decay for users inactive >7 days.""" 

293 cutoff = datetime.utcnow() - timedelta(days=7) 

294 wallets = db.query(ResonanceWallet).filter( 

295 ResonanceWallet.signal > 0, 

296 ResonanceWallet.signal_last_decay < cutoff 

297 ).all() 

298 

299 decayed_count = 0 

300 for w in wallets: 

301 days_inactive = (datetime.utcnow() - (w.signal_last_decay or w.created_at)).days 

302 days_to_decay = max(0, days_inactive - 7) 

303 if days_to_decay > 0: 

304 decay_rate = 0.002 * days_to_decay 

305 decay_amount = w.signal * min(decay_rate, 0.5) # cap at 50% 

306 w.signal = max(0, w.signal - decay_amount) 

307 w.signal_last_decay = datetime.utcnow() 

308 ResonanceService._log_transaction( 

309 db, w.user_id, 'signal', -decay_amount, w.signal, 

310 'decay', None, f'{days_to_decay}d inactivity decay') 

311 decayed_count += 1 

312 

313 return decayed_count 

314 

315 @staticmethod 

316 def get_leaderboard(db: Session, currency: str = 'pulse', 

317 limit: int = 50, offset: int = 0, 

318 region_id: str = None) -> List[Dict]: 

319 """Get leaderboard sorted by currency.""" 

320 col_map = { 

321 'pulse': ResonanceWallet.pulse, 

322 'spark': ResonanceWallet.spark_lifetime, 

323 'signal': ResonanceWallet.signal, 

324 'xp': ResonanceWallet.xp, 

325 'level': ResonanceWallet.level, 

326 } 

327 sort_col = col_map.get(currency, ResonanceWallet.pulse) 

328 

329 q = db.query(ResonanceWallet, User).join( 

330 User, User.id == ResonanceWallet.user_id) 

331 

332 if region_id: 

333 q = q.filter(User.region_id == region_id) 

334 

335 rows = q.order_by(desc(sort_col)).offset(offset).limit(limit).all() 

336 

337 result = [] 

338 for i, (wallet, user) in enumerate(rows, start=offset + 1): 

339 entry = wallet.to_dict() 

340 entry['rank'] = i 

341 entry['username'] = user.username 

342 entry['display_name'] = user.display_name 

343 entry['avatar_url'] = user.avatar_url 

344 entry['user_type'] = user.user_type 

345 result.append(entry) 

346 

347 return result 

348 

349 @staticmethod 

350 def get_transactions(db: Session, user_id: str, 

351 currency: str = None, 

352 limit: int = 50, offset: int = 0) -> List[Dict]: 

353 """Get transaction history for a user.""" 

354 q = db.query(ResonanceTransaction).filter_by(user_id=user_id) 

355 if currency: 

356 q = q.filter_by(currency=currency) 

357 txns = q.order_by(desc(ResonanceTransaction.created_at) 

358 ).offset(offset).limit(limit).all() 

359 return [t.to_dict() for t in txns] 

360 

361 @staticmethod 

362 def get_breakdown(db: Session, user_id: str) -> Dict: 

363 """Get detailed breakdown of a user's resonance.""" 

364 wallet = ResonanceService.get_wallet(db, user_id) 

365 if not wallet: 

366 return {} 

367 

368 # Count transactions by source_type 

369 source_counts = db.query( 

370 ResonanceTransaction.source_type, 

371 func.count(ResonanceTransaction.id), 

372 func.sum(ResonanceTransaction.amount) 

373 ).filter_by(user_id=user_id).group_by( 

374 ResonanceTransaction.source_type).all() 

375 

376 sources = {} 

377 for source_type, count, total in source_counts: 

378 sources[source_type] = {'count': count, 'total': float(total or 0)} 

379 

380 wallet['sources'] = sources 

381 return wallet