From c06509afe410df1425566d1ec8762024d3feee1d Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 20 Oct 2026 05:58:14 +0200 Subject: [PATCH] feat(wing): BM25 scorer as the independent cross-check (#205 S1) Co-Authored-By: Claude Sonnet 5.5 --- ingestion/internal/wing/bm25scorer.go | 92 ++++++++++++++++++++++ ingestion/internal/wing/bm25scorer_test.go | 45 +++++++++++ 2 files changed, 137 insertions(+) create mode 100644 ingestion/internal/wing/bm25scorer.go create mode 100644 ingestion/internal/wing/bm25scorer_test.go diff --git a/ingestion/internal/wing/bm25scorer.go b/ingestion/internal/wing/bm25scorer.go new file mode 100644 index 0000000..74cd037 --- /dev/null +++ b/ingestion/internal/wing/bm25scorer.go @@ -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 +} diff --git a/ingestion/internal/wing/bm25scorer_test.go b/ingestion/internal/wing/bm25scorer_test.go new file mode 100644 index 0000000..b648ca3 --- /dev/null +++ b/ingestion/internal/wing/bm25scorer_test.go @@ -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")) +}