feat(wing): BM25 scorer as the independent cross-check (#205 S1)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 05:58:14 +02:00
co-authored by Claude Sonnet 5.5
parent 85d8ecb5c7
commit c06509afe4
2 changed files with 137 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
package wing
import (
"sort"
"strings"
"unicode"
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
)
const (
maxQueryTerms = 12
minTermLen = 5
hitsPerWing = 3
)
// 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: hitsPerWing + 1})
if err != nil {
continue
}
var sum float64
n := 0
for _, h := range hits {
if b.exclude != "" && h.Path == b.exclude {
continue
}
if n == hitsPerWing {
break
}
sum += float64(h.Score)
n++
}
if sum > 0 {
out[w] = sum
}
}
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
}
@@ -0,0 +1,45 @@
package wing_test
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/wing"
)
func writePage(t *testing.T, dir, rel, body string) {
t.Helper()
full := filepath.Join(dir, rel)
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
require.NoError(t, os.WriteFile(full, []byte(body), 0o644))
}
func scorerBrain(t *testing.T) string {
dir := t.TempDir()
writePage(t, dir, "wiki/infra/facts/k3s.md", "---\ntitle: K3s cluster\n---\nkubernetes cluster nodes scheduling pods deployment\n")
writePage(t, dir, "wiki/finance/facts/invoice.md", "---\ntitle: Invoices\n---\ninvoice payment bookkeeping ledger reconciliation\n")
return dir
}
func TestBM25Scorer_RanksTheWingWhosePagesMatch(t *testing.T) {
s := wing.NewBM25Scorer(scorerBrain(t), []string{"infra", "finance"}, "")
got := s.Score("notes about kubernetes cluster scheduling and pods")
assert.Greater(t, got["infra"], got["finance"])
assert.Greater(t, got["infra"], 0.0)
}
func TestBM25Scorer_ExcludesThePageBeingClassified(t *testing.T) {
// A page must not vouch for its own wing when it is the backtest sample.
s := wing.NewBM25Scorer(scorerBrain(t), []string{"infra", "finance"}, "wiki/infra/facts/k3s.md")
got := s.Score("kubernetes cluster nodes scheduling pods deployment")
assert.Zero(t, got["infra"], "the excluded page is the only infra match")
}
func TestBM25Scorer_NoUsableTermsScoresNothing(t *testing.T) {
s := wing.NewBM25Scorer(scorerBrain(t), []string{"infra", "finance"}, "")
assert.Empty(t, s.Score("a an to of"))
}