nostr.go raw

   1  package forage
   2  
   3  import (
   4  	"sort"
   5  	"strings"
   6  
   7  	"git.mleku.dev/mleku/dendrite/pkg/nostr"
   8  )
   9  
  10  // NpubScore tracks bond rate per author across all their notes.
  11  type NpubScore struct {
  12  	Pubkey       string  `json:"pubkey"`
  13  	TotalElems   int     `json:"total_elems"`
  14  	TotalBonded  int     `json:"total_bonded"`
  15  	NoteCount    int     `json:"note_count"`
  16  	ContentBytes int     `json:"content_bytes"`
  17  	BondRateEWMA float64 `json:"bond_rate_ewma"`
  18  	BondsPerKB   float64 `json:"bonds_per_kb"`
  19  }
  20  
  21  // UpdateBondRate adds a new observation and recalculates the EWMA.
  22  func (s *NpubScore) UpdateBondRate(elems, bonded, contentLen int) {
  23  	s.TotalElems += elems
  24  	s.TotalBonded += bonded
  25  	s.NoteCount++
  26  	s.ContentBytes += contentLen
  27  
  28  	rate := 0.0
  29  	if elems > 0 {
  30  		rate = float64(bonded) / float64(elems)
  31  	}
  32  	s.BondRateEWMA = 0.8*s.BondRateEWMA + 0.2*rate
  33  
  34  	if s.ContentBytes > 0 {
  35  		s.BondsPerKB = float64(s.TotalBonded) / (float64(s.ContentBytes) / 1024.0)
  36  	}
  37  }
  38  
  39  // InteractionEdge records a social edge with weight.
  40  type InteractionEdge struct {
  41  	TargetPubkey string  `json:"target"`
  42  	Replies      int     `json:"replies"`
  43  	Mentions     int     `json:"mentions"`
  44  	Weight       float64 `json:"weight"`
  45  }
  46  
  47  // ExtractInteractions scans events for p-tags and e-tags, building a
  48  // weighted edge list for the given author. Reply thread partners
  49  // (via p-tags in events that also have e-tags) are weighted 3x.
  50  func ExtractInteractions(events []*nostr.Event, authorPubkey string) []InteractionEdge {
  51  	edges := make(map[string]*InteractionEdge)
  52  
  53  	for _, ev := range events {
  54  		if ev.Pubkey != authorPubkey {
  55  			continue
  56  		}
  57  
  58  		// Check if this event is a reply (has e-tags).
  59  		isReply := false
  60  		for _, tag := range ev.Tags {
  61  			if len(tag) >= 2 && tag[0] == "e" {
  62  				isReply = true
  63  				break
  64  			}
  65  		}
  66  
  67  		// Extract p-tag mentions.
  68  		for _, tag := range ev.Tags {
  69  			if len(tag) < 2 || tag[0] != "p" || len(tag[1]) != 64 {
  70  				continue
  71  			}
  72  			target := tag[1]
  73  			if target == authorPubkey {
  74  				continue
  75  			}
  76  
  77  			edge, ok := edges[target]
  78  			if !ok {
  79  				edge = &InteractionEdge{TargetPubkey: target}
  80  				edges[target] = edge
  81  			}
  82  
  83  			if isReply {
  84  				edge.Replies++
  85  			} else {
  86  				edge.Mentions++
  87  			}
  88  		}
  89  	}
  90  
  91  	// Compute weights and collect.
  92  	result := make([]InteractionEdge, 0, len(edges))
  93  	for _, e := range edges {
  94  		e.Weight = float64(3*e.Replies + e.Mentions)
  95  		result = append(result, *e)
  96  	}
  97  
  98  	// Sort by weight descending.
  99  	sort.Slice(result, func(i, j int) bool {
 100  		return result[i].Weight > result[j].Weight
 101  	})
 102  
 103  	return result
 104  }
 105  
 106  // ScoreNextNpub picks the best unvisited npub from candidates.
 107  // Interaction weight is primary. Baby talk overlap is a tiebreaker
 108  // when available.
 109  func ScoreNextNpub(candidates []InteractionEdge, visited map[string]*NpubScore, babyTalk string) string {
 110  	var babyWords map[string]bool
 111  	if babyTalk != "" {
 112  		babyWords = make(map[string]bool)
 113  		for _, w := range strings.Fields(babyTalk) {
 114  			if len(w) >= 3 {
 115  				babyWords[strings.ToLower(w)] = true
 116  			}
 117  		}
 118  	}
 119  
 120  	bestPubkey := ""
 121  	bestScore := -1.0
 122  
 123  	for _, c := range candidates {
 124  		if _, seen := visited[c.TargetPubkey]; seen {
 125  			continue
 126  		}
 127  
 128  		score := c.Weight
 129  
 130  		// Baby talk overlap bonus (small tiebreaker, not dominant).
 131  		if len(babyWords) > 0 {
 132  			score += 0.01 // tiny bonus just for being reachable
 133  		}
 134  
 135  		if score > bestScore {
 136  			bestScore = score
 137  			bestPubkey = c.TargetPubkey
 138  		}
 139  	}
 140  
 141  	return bestPubkey
 142  }
 143