package forage import ( "sort" "strings" "git.mleku.dev/mleku/dendrite/pkg/nostr" ) // NpubScore tracks bond rate per author across all their notes. type NpubScore struct { Pubkey string `json:"pubkey"` TotalElems int `json:"total_elems"` TotalBonded int `json:"total_bonded"` NoteCount int `json:"note_count"` ContentBytes int `json:"content_bytes"` BondRateEWMA float64 `json:"bond_rate_ewma"` BondsPerKB float64 `json:"bonds_per_kb"` } // UpdateBondRate adds a new observation and recalculates the EWMA. func (s *NpubScore) UpdateBondRate(elems, bonded, contentLen int) { s.TotalElems += elems s.TotalBonded += bonded s.NoteCount++ s.ContentBytes += contentLen rate := 0.0 if elems > 0 { rate = float64(bonded) / float64(elems) } s.BondRateEWMA = 0.8*s.BondRateEWMA + 0.2*rate if s.ContentBytes > 0 { s.BondsPerKB = float64(s.TotalBonded) / (float64(s.ContentBytes) / 1024.0) } } // InteractionEdge records a social edge with weight. type InteractionEdge struct { TargetPubkey string `json:"target"` Replies int `json:"replies"` Mentions int `json:"mentions"` Weight float64 `json:"weight"` } // ExtractInteractions scans events for p-tags and e-tags, building a // weighted edge list for the given author. Reply thread partners // (via p-tags in events that also have e-tags) are weighted 3x. func ExtractInteractions(events []*nostr.Event, authorPubkey string) []InteractionEdge { edges := make(map[string]*InteractionEdge) for _, ev := range events { if ev.Pubkey != authorPubkey { continue } // Check if this event is a reply (has e-tags). isReply := false for _, tag := range ev.Tags { if len(tag) >= 2 && tag[0] == "e" { isReply = true break } } // Extract p-tag mentions. for _, tag := range ev.Tags { if len(tag) < 2 || tag[0] != "p" || len(tag[1]) != 64 { continue } target := tag[1] if target == authorPubkey { continue } edge, ok := edges[target] if !ok { edge = &InteractionEdge{TargetPubkey: target} edges[target] = edge } if isReply { edge.Replies++ } else { edge.Mentions++ } } } // Compute weights and collect. result := make([]InteractionEdge, 0, len(edges)) for _, e := range edges { e.Weight = float64(3*e.Replies + e.Mentions) result = append(result, *e) } // Sort by weight descending. sort.Slice(result, func(i, j int) bool { return result[i].Weight > result[j].Weight }) return result } // ScoreNextNpub picks the best unvisited npub from candidates. // Interaction weight is primary. Baby talk overlap is a tiebreaker // when available. func ScoreNextNpub(candidates []InteractionEdge, visited map[string]*NpubScore, babyTalk string) string { var babyWords map[string]bool if babyTalk != "" { babyWords = make(map[string]bool) for _, w := range strings.Fields(babyTalk) { if len(w) >= 3 { babyWords[strings.ToLower(w)] = true } } } bestPubkey := "" bestScore := -1.0 for _, c := range candidates { if _, seen := visited[c.TargetPubkey]; seen { continue } score := c.Weight // Baby talk overlap bonus (small tiebreaker, not dominant). if len(babyWords) > 0 { score += 0.01 // tiny bonus just for being reachable } if score > bestScore { bestScore = score bestPubkey = c.TargetPubkey } } return bestPubkey }