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