package wing import ( "sort" "strings" "unicode" "github.com/mathiasbq/hyperguild/ingestion/internal/search" ) const ( maxQueryTerms = 12 minTermLen = 5 ) // BM25Scorer scores a note against each wing by running the brain's own BM25 // search scoped to that wing. It is deliberately a different mechanism from // the LLM, so agreement between the two is evidence and not an echo. type BM25Scorer struct { brainDir string wings []string exclude string } // NewBM25Scorer scores against wings under brainDir. exclude is a brain- // relative path ignored in results (the backtest sample itself); "" for none. func NewBM25Scorer(brainDir string, wings []string, exclude string) *BM25Scorer { return &BM25Scorer{brainDir: brainDir, wings: wings, exclude: exclude} } // Score implements Scorer. A wing whose search fails scores 0 rather than // failing the note: the Resolver treats "no clear opinion" as an Exception. func (b *BM25Scorer) Score(text string) map[string]float64 { query := strings.Join(topTerms(text), " ") out := map[string]float64{} if query == "" { return out } for _, w := range b.wings { hits, err := search.Query(b.brainDir, search.QueryOptions{Query: query, Wing: w, Limit: 2}) if err != nil { continue } for _, h := range hits { if b.exclude != "" && h.Path == b.exclude { continue } // Hits come best-first: a wing is scored by its single best // page, so a big wing cannot win on volume alone. out[w] = float64(h.Score) break } } return out } // topTerms picks the most frequent longer words, ties broken alphabetically // so the query is deterministic. func topTerms(text string) []string { if len(text) > maxNoteChars { text = text[:maxNoteChars] } counts := map[string]int{} for _, f := range strings.FieldsFunc(strings.ToLower(text), func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) { if len([]rune(f)) >= minTermLen { counts[f]++ } } terms := make([]string, 0, len(counts)) for t := range counts { terms = append(terms, t) } sort.Slice(terms, func(i, j int) bool { if counts[terms[i]] != counts[terms[j]] { return counts[terms[i]] > counts[terms[j]] } return terms[i] < terms[j] }) if len(terms) > maxQueryTerms { terms = terms[:maxQueryTerms] } return terms }