Files
mathiasandClaude Sonnet 5.5 62de8ac2e6 feat(wing): score a wing by its best page, not summed hits; add wingbacktest command (#205 S1)
A first backtest showed BM25 summed over a wing's top hits favours big wings
(0/4 agreement). Best-page scoring is volume-robust; pinned by a test that
failed before the change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 06:04:45 +02:00

86 lines
2.3 KiB
Go

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
}