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:
@@ -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"))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user