chore: import upstream snapshot with attribution
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
package quality
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/zzet/gortex/internal/platform"
|
||||
)
|
||||
|
||||
// ConfidenceRecord captures the candidate-score distribution for
|
||||
// one search call. The distribution shape (top-1 vs top-2 gap,
|
||||
// std-dev, ratio) tells you how confident the ranker was — a sharp
|
||||
// top-1 with a long tail is a high-confidence answer; a flat
|
||||
// distribution is the ranker shrugging.
|
||||
type ConfidenceRecord struct {
|
||||
TS time.Time `json:"ts"`
|
||||
Query string `json:"query"`
|
||||
Top1 float64 `json:"top1"`
|
||||
Top2 float64 `json:"top2,omitempty"`
|
||||
Mean float64 `json:"mean"`
|
||||
StdDev float64 `json:"std_dev"`
|
||||
Ratio12 float64 `json:"ratio_top1_top2,omitempty"` // top1 / top2; >>1 = confident
|
||||
K int `json:"k"` // number of scored candidates
|
||||
}
|
||||
|
||||
// ConfidenceFromScores derives a ConfidenceRecord from a slice of
|
||||
// per-candidate scores. Negative / zero K returns a zero record so
|
||||
// the caller can opt out by passing an empty slice.
|
||||
func ConfidenceFromScores(query string, scores []float64) ConfidenceRecord {
|
||||
r := ConfidenceRecord{
|
||||
TS: time.Now().UTC(),
|
||||
Query: query,
|
||||
K: len(scores),
|
||||
}
|
||||
if len(scores) == 0 {
|
||||
return r
|
||||
}
|
||||
sorted := make([]float64, len(scores))
|
||||
copy(sorted, scores)
|
||||
sort.Sort(sort.Reverse(sort.Float64Slice(sorted)))
|
||||
r.Top1 = sorted[0]
|
||||
if len(sorted) > 1 {
|
||||
r.Top2 = sorted[1]
|
||||
if r.Top2 != 0 {
|
||||
r.Ratio12 = r.Top1 / r.Top2
|
||||
}
|
||||
}
|
||||
var sum float64
|
||||
for _, v := range scores {
|
||||
sum += v
|
||||
}
|
||||
r.Mean = sum / float64(len(scores))
|
||||
var ss float64
|
||||
for _, v := range scores {
|
||||
ss += (v - r.Mean) * (v - r.Mean)
|
||||
}
|
||||
r.StdDev = math.Sqrt(ss / float64(len(scores)))
|
||||
return r
|
||||
}
|
||||
|
||||
// ConfidenceTracker appends records to a JSONL log on disk. Safe
|
||||
// for in-process concurrent appends because the file is opened
|
||||
// with O_APPEND.
|
||||
type ConfidenceTracker struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
// NewConfidenceTracker returns a tracker bound to a log path.
|
||||
// Empty path is allowed — Record then no-ops without erroring.
|
||||
func NewConfidenceTracker(path string) *ConfidenceTracker {
|
||||
return &ConfidenceTracker{Path: path}
|
||||
}
|
||||
|
||||
// DefaultConfidenceLogPath is the canonical persistence location.
|
||||
// An absolute $XDG_CACHE_HOME is honoured; otherwise the log stays
|
||||
// under os.UserCacheDir() as before. Returns empty when no cache dir
|
||||
// can be resolved.
|
||||
func DefaultConfidenceLogPath() string {
|
||||
if v := os.Getenv("XDG_CACHE_HOME"); v == "" || !filepath.IsAbs(v) {
|
||||
if base, err := os.UserCacheDir(); err != nil || base == "" {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return filepath.Join(platform.OSCacheDir(), "confidence.jsonl")
|
||||
}
|
||||
|
||||
// Record appends one record to the log. No-op when Path is empty.
|
||||
func (t *ConfidenceTracker) Record(r ConfidenceRecord) error {
|
||||
if t.Path == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(t.Path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(t.Path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
body, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = append(body, '\n')
|
||||
_, err = f.Write(body)
|
||||
return err
|
||||
}
|
||||
|
||||
// LoadConfidenceLog reads the log at path and returns records with
|
||||
// ts >= since (zero since returns all). Malformed lines are
|
||||
// skipped so a previous crash mid-write doesn't break readers.
|
||||
func LoadConfidenceLog(path string, since time.Time) ([]ConfidenceRecord, error) {
|
||||
if path == "" {
|
||||
return nil, nil
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("open confidence log: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
r := bufio.NewReaderSize(f, 64*1024)
|
||||
out := []ConfidenceRecord{}
|
||||
for {
|
||||
line, err := r.ReadBytes('\n')
|
||||
if len(line) > 0 {
|
||||
if n := len(line); n > 0 && line[n-1] == '\n' {
|
||||
line = line[:n-1]
|
||||
}
|
||||
if len(line) > 0 {
|
||||
var rec ConfidenceRecord
|
||||
if json.Unmarshal(line, &rec) == nil {
|
||||
if since.IsZero() || !rec.TS.Before(since) {
|
||||
out = append(out, rec)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return out, fmt.Errorf("read confidence log: %w", err)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ConfidenceSummary aggregates a slice of records into the headline
|
||||
// statistics the `gortex eval quality confidence` subcommand emits.
|
||||
type ConfidenceSummary struct {
|
||||
Count int `json:"count"`
|
||||
MedianTop1 float64 `json:"median_top1"`
|
||||
MedianRatio12 float64 `json:"median_ratio_top1_top2"`
|
||||
MedianStdDev float64 `json:"median_std_dev"`
|
||||
LowConfidenceCount int `json:"low_confidence_count"` // records with Ratio12 < 1.25
|
||||
}
|
||||
|
||||
// SummarizeConfidence reduces a slice of records into the summary.
|
||||
// Median across records; "low confidence" = Ratio12 < 1.25 (the
|
||||
// ranker barely separated top-1 from top-2).
|
||||
func SummarizeConfidence(records []ConfidenceRecord) ConfidenceSummary {
|
||||
s := ConfidenceSummary{Count: len(records)}
|
||||
if len(records) == 0 {
|
||||
return s
|
||||
}
|
||||
top1s := make([]float64, 0, len(records))
|
||||
ratios := make([]float64, 0, len(records))
|
||||
stds := make([]float64, 0, len(records))
|
||||
for _, r := range records {
|
||||
top1s = append(top1s, r.Top1)
|
||||
stds = append(stds, r.StdDev)
|
||||
if r.Ratio12 > 0 {
|
||||
ratios = append(ratios, r.Ratio12)
|
||||
}
|
||||
if r.Ratio12 > 0 && r.Ratio12 < 1.25 {
|
||||
s.LowConfidenceCount++
|
||||
}
|
||||
}
|
||||
s.MedianTop1 = medianFloats(top1s)
|
||||
s.MedianRatio12 = medianFloats(ratios)
|
||||
s.MedianStdDev = medianFloats(stds)
|
||||
return s
|
||||
}
|
||||
|
||||
func medianFloats(vs []float64) float64 {
|
||||
if len(vs) == 0 {
|
||||
return 0
|
||||
}
|
||||
c := make([]float64, len(vs))
|
||||
copy(c, vs)
|
||||
sort.Float64s(c)
|
||||
return c[len(c)/2]
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package quality
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConfidenceFromScores_Basic(t *testing.T) {
|
||||
scores := []float64{0.95, 0.7, 0.5, 0.3}
|
||||
r := ConfidenceFromScores("q", scores)
|
||||
if r.Top1 != 0.95 || r.Top2 != 0.7 {
|
||||
t.Errorf("top1/top2 = %.2f/%.2f, want 0.95/0.7", r.Top1, r.Top2)
|
||||
}
|
||||
wantRatio := 0.95 / 0.7
|
||||
if math.Abs(r.Ratio12-wantRatio) > 1e-9 {
|
||||
t.Errorf("Ratio12 = %.4f, want %.4f", r.Ratio12, wantRatio)
|
||||
}
|
||||
if r.K != 4 {
|
||||
t.Errorf("K = %d, want 4", r.K)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfidenceFromScores_Empty(t *testing.T) {
|
||||
r := ConfidenceFromScores("q", nil)
|
||||
if r.K != 0 || r.Top1 != 0 || r.Top2 != 0 {
|
||||
t.Errorf("empty input should yield zero record, got %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfidenceFromScores_SingleScore(t *testing.T) {
|
||||
r := ConfidenceFromScores("q", []float64{0.5})
|
||||
if r.Top1 != 0.5 || r.Top2 != 0 || r.Ratio12 != 0 {
|
||||
t.Errorf("single-score = %+v, want top1=0.5 others=0", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfidenceTracker_RoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "conf.jsonl")
|
||||
tr := NewConfidenceTracker(path)
|
||||
for i := range 3 {
|
||||
rec := ConfidenceFromScores(
|
||||
"q",
|
||||
[]float64{0.9, 0.8 - 0.1*float64(i), 0.5, 0.4},
|
||||
)
|
||||
if err := tr.Record(rec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := LoadConfidenceLog(path, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Errorf("loaded %d records, want 3", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfidenceTracker_EmptyPathNoop(t *testing.T) {
|
||||
tr := NewConfidenceTracker("")
|
||||
if err := tr.Record(ConfidenceFromScores("q", []float64{1})); err != nil {
|
||||
t.Errorf("empty-path tracker should no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfidenceLog_FiltersSince(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "conf.jsonl")
|
||||
tr := NewConfidenceTracker(path)
|
||||
now := time.Now().UTC()
|
||||
|
||||
old := ConfidenceFromScores("old", []float64{0.5})
|
||||
old.TS = now.Add(-48 * time.Hour)
|
||||
_ = tr.Record(old)
|
||||
|
||||
recent := ConfidenceFromScores("recent", []float64{0.7})
|
||||
recent.TS = now
|
||||
_ = tr.Record(recent)
|
||||
|
||||
got, _ := LoadConfidenceLog(path, now.Add(-1*time.Hour))
|
||||
if len(got) != 1 {
|
||||
t.Errorf("since-filter = %d records, want 1", len(got))
|
||||
}
|
||||
if got[0].Query != "recent" {
|
||||
t.Errorf("kept wrong record: %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfidenceLog_MissingFile(t *testing.T) {
|
||||
got, err := LoadConfidenceLog(filepath.Join(t.TempDir(), "missing.jsonl"), time.Time{})
|
||||
if err != nil {
|
||||
t.Errorf("missing file should not error, got %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("missing file should yield empty, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfidenceLog_TolerateMalformedLines(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "conf.jsonl")
|
||||
body := `{"ts":"2026-05-18T10:00:00Z","query":"good","top1":0.9,"k":1}` + "\n" +
|
||||
`{not json}` + "\n" +
|
||||
`{"ts":"2026-05-18T11:00:00Z","query":"also-good","top1":0.8,"k":1}` + "\n"
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := LoadConfidenceLog(path, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("malformed-line tolerance failed: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Errorf("got %d records, want 2 (malformed dropped)", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeConfidence(t *testing.T) {
|
||||
records := []ConfidenceRecord{
|
||||
{Top1: 0.9, Top2: 0.7, Ratio12: 1.286, StdDev: 0.2},
|
||||
{Top1: 0.5, Top2: 0.45, Ratio12: 1.11, StdDev: 0.1}, // low confidence
|
||||
{Top1: 0.8, Top2: 0.3, Ratio12: 2.67, StdDev: 0.3},
|
||||
}
|
||||
s := SummarizeConfidence(records)
|
||||
if s.Count != 3 {
|
||||
t.Errorf("count = %d, want 3", s.Count)
|
||||
}
|
||||
if s.LowConfidenceCount != 1 {
|
||||
t.Errorf("low confidence = %d, want 1 (Ratio12 < 1.25)", s.LowConfidenceCount)
|
||||
}
|
||||
if s.MedianTop1 != 0.8 {
|
||||
t.Errorf("median top1 = %.2f, want 0.8", s.MedianTop1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeConfidence_Empty(t *testing.T) {
|
||||
s := SummarizeConfidence(nil)
|
||||
if s.Count != 0 || s.MedianTop1 != 0 {
|
||||
t.Errorf("empty input should yield zero summary, got %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfidenceLogPath(t *testing.T) {
|
||||
got := DefaultConfidenceLogPath()
|
||||
if got == "" {
|
||||
return // UserCacheDir unavailable
|
||||
}
|
||||
if filepath.Base(got) != "confidence.jsonl" {
|
||||
t.Errorf("default path basename = %q, want confidence.jsonl", filepath.Base(got))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Package quality provides measurement infrastructure for the
|
||||
// retrieval pipeline: embedder drift detection, retrieval-confidence
|
||||
// tracking, query-log replay (top-k churn between two ranker
|
||||
// configurations), and weight-tuning analysis.
|
||||
//
|
||||
// All four are surface-level analyzers — they read substrate already
|
||||
// shipped (savings store, search index, rerank pipeline) and produce
|
||||
// markdown / JSON artifacts. No new state in the hot path.
|
||||
package quality
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/zzet/gortex/internal/platform"
|
||||
)
|
||||
|
||||
// EmbedderFingerprint captures the identity of the active embedder
|
||||
// at one point in time. Persisting it lets the drift detector flag
|
||||
// silent provider / model / dimension changes that would otherwise
|
||||
// produce confusing recall regressions.
|
||||
type EmbedderFingerprint struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
ModelRevision string `json:"model_revision,omitempty"`
|
||||
EmbeddingDim int `json:"embedding_dim"`
|
||||
SampleVecSHA256 string `json:"sample_vec_sha256,omitempty"`
|
||||
RecordedAt time.Time `json:"recorded_at"`
|
||||
}
|
||||
|
||||
// DriftWarning is the structured signal the detector emits when the
|
||||
// current fingerprint differs from the stored one. Empty Changes
|
||||
// slice means no drift.
|
||||
type DriftWarning struct {
|
||||
Previous EmbedderFingerprint `json:"previous"`
|
||||
Current EmbedderFingerprint `json:"current"`
|
||||
Changes []string `json:"changes"`
|
||||
}
|
||||
|
||||
// HasDrift reports whether the warning is non-empty — convenience
|
||||
// for callers that just want a boolean (e.g. CI gates).
|
||||
func (w DriftWarning) HasDrift() bool { return len(w.Changes) > 0 }
|
||||
|
||||
// DefaultFingerprintPath returns the canonical persistence location.
|
||||
// An absolute $XDG_CACHE_HOME is honoured; otherwise the file stays
|
||||
// under os.UserCacheDir() as before. Returns empty when the cache dir
|
||||
// is unavailable; callers should treat empty as "don't persist, just
|
||||
// compare in-memory".
|
||||
func DefaultFingerprintPath() string {
|
||||
if v := os.Getenv("XDG_CACHE_HOME"); v == "" || !filepath.IsAbs(v) {
|
||||
if base, err := os.UserCacheDir(); err != nil || base == "" {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return filepath.Join(platform.OSCacheDir(), "embedding-fingerprint.json")
|
||||
}
|
||||
|
||||
// DriftDetector wraps the fingerprint persistence and the
|
||||
// comparison logic. Concurrency: not safe — call from one goroutine
|
||||
// per detector instance.
|
||||
type DriftDetector struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
// NewDriftDetector returns a detector bound to a persistence path.
|
||||
// Empty path is allowed — the detector still compares in-memory but
|
||||
// never writes to disk.
|
||||
func NewDriftDetector(path string) *DriftDetector {
|
||||
return &DriftDetector{Path: path}
|
||||
}
|
||||
|
||||
// LoadPrevious reads the most recent fingerprint, or returns
|
||||
// (zero-value, nil) when none exists. Real I/O errors propagate so a
|
||||
// permission problem surfaces.
|
||||
func (d *DriftDetector) LoadPrevious() (EmbedderFingerprint, error) {
|
||||
if d.Path == "" {
|
||||
return EmbedderFingerprint{}, nil
|
||||
}
|
||||
raw, err := os.ReadFile(d.Path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return EmbedderFingerprint{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return EmbedderFingerprint{}, fmt.Errorf("read fingerprint: %w", err)
|
||||
}
|
||||
var fp EmbedderFingerprint
|
||||
if err := json.Unmarshal(raw, &fp); err != nil {
|
||||
return EmbedderFingerprint{}, fmt.Errorf("parse fingerprint: %w", err)
|
||||
}
|
||||
return fp, nil
|
||||
}
|
||||
|
||||
// Save writes the current fingerprint atomically (temp + rename).
|
||||
// Silently no-ops when the detector has no persistence path.
|
||||
func (d *DriftDetector) Save(fp EmbedderFingerprint) error {
|
||||
if d.Path == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(d.Path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := json.MarshalIndent(fp, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := d.Path + ".tmp"
|
||||
if err := os.WriteFile(tmp, body, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, d.Path)
|
||||
}
|
||||
|
||||
// Compare returns the drift warning for current vs the stored
|
||||
// fingerprint. The persistence file is NOT updated by Compare —
|
||||
// callers decide when to promote (e.g. only after operator
|
||||
// confirmation, or always in CI).
|
||||
func (d *DriftDetector) Compare(current EmbedderFingerprint) (DriftWarning, error) {
|
||||
prev, err := d.LoadPrevious()
|
||||
if err != nil {
|
||||
return DriftWarning{}, err
|
||||
}
|
||||
return DiffFingerprints(prev, current), nil
|
||||
}
|
||||
|
||||
// DiffFingerprints is the pure comparison logic — exported so tests
|
||||
// and standalone consumers can use it without touching disk.
|
||||
func DiffFingerprints(prev, current EmbedderFingerprint) DriftWarning {
|
||||
w := DriftWarning{Previous: prev, Current: current}
|
||||
if prev == (EmbedderFingerprint{}) {
|
||||
// No previous record — first run; not drift.
|
||||
return w
|
||||
}
|
||||
if prev.Provider != current.Provider {
|
||||
w.Changes = append(w.Changes, fmt.Sprintf("provider: %q → %q", prev.Provider, current.Provider))
|
||||
}
|
||||
if prev.Model != current.Model {
|
||||
w.Changes = append(w.Changes, fmt.Sprintf("model: %q → %q", prev.Model, current.Model))
|
||||
}
|
||||
if prev.ModelRevision != current.ModelRevision && (prev.ModelRevision != "" || current.ModelRevision != "") {
|
||||
w.Changes = append(w.Changes, fmt.Sprintf("model_revision: %q → %q", prev.ModelRevision, current.ModelRevision))
|
||||
}
|
||||
if prev.EmbeddingDim != current.EmbeddingDim {
|
||||
w.Changes = append(w.Changes, fmt.Sprintf("embedding_dim: %d → %d", prev.EmbeddingDim, current.EmbeddingDim))
|
||||
}
|
||||
if prev.SampleVecSHA256 != current.SampleVecSHA256 && (prev.SampleVecSHA256 != "" || current.SampleVecSHA256 != "") {
|
||||
// Vector-shape change without dim change usually means the
|
||||
// model was re-quantized or the input pre-processor
|
||||
// changed. Worth flagging.
|
||||
w.Changes = append(w.Changes, "sample_vec_sha256 changed (re-quantized or pre-processor swap?)")
|
||||
}
|
||||
return w
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package quality
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDiffFingerprints_NoPreviousIsNoDrift(t *testing.T) {
|
||||
cur := EmbedderFingerprint{Provider: "hugot", Model: "MiniLM-L6-v2", EmbeddingDim: 384}
|
||||
w := DiffFingerprints(EmbedderFingerprint{}, cur)
|
||||
if w.HasDrift() {
|
||||
t.Errorf("no previous fingerprint should not flag drift; got %v", w.Changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffFingerprints_ProviderChangeFlagged(t *testing.T) {
|
||||
prev := EmbedderFingerprint{Provider: "hugot", Model: "MiniLM-L6-v2", EmbeddingDim: 384}
|
||||
cur := EmbedderFingerprint{Provider: "openai", Model: "text-embedding-3-small", EmbeddingDim: 384}
|
||||
w := DiffFingerprints(prev, cur)
|
||||
if !w.HasDrift() {
|
||||
t.Fatal("provider change should flag drift")
|
||||
}
|
||||
hasProvider := false
|
||||
hasModel := false
|
||||
for _, c := range w.Changes {
|
||||
if contains(c, "provider:") {
|
||||
hasProvider = true
|
||||
}
|
||||
if contains(c, "model:") {
|
||||
hasModel = true
|
||||
}
|
||||
}
|
||||
if !hasProvider || !hasModel {
|
||||
t.Errorf("expected provider + model in changes, got %v", w.Changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffFingerprints_DimChangeFlagged(t *testing.T) {
|
||||
prev := EmbedderFingerprint{Provider: "hugot", Model: "MiniLM-L6-v2", EmbeddingDim: 384}
|
||||
cur := EmbedderFingerprint{Provider: "hugot", Model: "MiniLM-L6-v2", EmbeddingDim: 768}
|
||||
w := DiffFingerprints(prev, cur)
|
||||
if !w.HasDrift() {
|
||||
t.Fatal("dim change should flag drift")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffFingerprints_IdenticalIsNoDrift(t *testing.T) {
|
||||
fp := EmbedderFingerprint{Provider: "hugot", Model: "MiniLM-L6-v2", EmbeddingDim: 384, SampleVecSHA256: "abc"}
|
||||
w := DiffFingerprints(fp, fp)
|
||||
if w.HasDrift() {
|
||||
t.Errorf("identical fingerprints should not flag drift; got %v", w.Changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffFingerprints_SampleVecChangeFlagged(t *testing.T) {
|
||||
prev := EmbedderFingerprint{Provider: "hugot", Model: "M", EmbeddingDim: 384, SampleVecSHA256: "abc"}
|
||||
cur := EmbedderFingerprint{Provider: "hugot", Model: "M", EmbeddingDim: 384, SampleVecSHA256: "xyz"}
|
||||
w := DiffFingerprints(prev, cur)
|
||||
if !w.HasDrift() {
|
||||
t.Fatal("sample-vec change should flag drift")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriftDetector_RoundTripFingerprint(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "fp.json")
|
||||
d := NewDriftDetector(path)
|
||||
|
||||
want := EmbedderFingerprint{
|
||||
Provider: "hugot",
|
||||
Model: "MiniLM-L6-v2",
|
||||
ModelRevision: "fp32",
|
||||
EmbeddingDim: 384,
|
||||
SampleVecSHA256: "deadbeef",
|
||||
RecordedAt: time.Now().UTC().Round(time.Second),
|
||||
}
|
||||
if err := d.Save(want); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
got, err := d.LoadPrevious()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPrevious: %v", err)
|
||||
}
|
||||
if got.Provider != want.Provider || got.EmbeddingDim != want.EmbeddingDim {
|
||||
t.Errorf("round-trip lost data: got %+v want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriftDetector_LoadMissingReturnsZero(t *testing.T) {
|
||||
d := NewDriftDetector(filepath.Join(t.TempDir(), "missing.json"))
|
||||
got, err := d.LoadPrevious()
|
||||
if err != nil {
|
||||
t.Errorf("missing file should not error, got %v", err)
|
||||
}
|
||||
if got != (EmbedderFingerprint{}) {
|
||||
t.Errorf("missing file should yield zero, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriftDetector_EmptyPathSilent(t *testing.T) {
|
||||
d := NewDriftDetector("")
|
||||
if err := d.Save(EmbedderFingerprint{Provider: "x"}); err != nil {
|
||||
t.Errorf("empty path Save should be no-op, got %v", err)
|
||||
}
|
||||
got, err := d.LoadPrevious()
|
||||
if err != nil || got != (EmbedderFingerprint{}) {
|
||||
t.Errorf("empty path LoadPrevious should return zero / nil, got %+v / %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriftDetector_Compare_EndToEnd(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
d := NewDriftDetector(filepath.Join(dir, "fp.json"))
|
||||
|
||||
// First compare: no previous record → no drift.
|
||||
cur := EmbedderFingerprint{Provider: "hugot", Model: "A", EmbeddingDim: 384}
|
||||
w, err := d.Compare(cur)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.HasDrift() {
|
||||
t.Error("first comparison should report no drift")
|
||||
}
|
||||
_ = d.Save(cur)
|
||||
|
||||
// Second compare with same fingerprint: still no drift.
|
||||
w, _ = d.Compare(cur)
|
||||
if w.HasDrift() {
|
||||
t.Errorf("identical fingerprint should not drift, got %v", w.Changes)
|
||||
}
|
||||
|
||||
// Change the model: should drift.
|
||||
cur.Model = "B"
|
||||
w, _ = d.Compare(cur)
|
||||
if !w.HasDrift() {
|
||||
t.Error("model change should drift")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultFingerprintPath(t *testing.T) {
|
||||
got := DefaultFingerprintPath()
|
||||
if got == "" {
|
||||
// Acceptable when UserCacheDir is unavailable; just don't panic.
|
||||
return
|
||||
}
|
||||
if filepath.Base(got) != "embedding-fingerprint.json" {
|
||||
t.Errorf("default path basename = %q, want embedding-fingerprint.json", filepath.Base(got))
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (s == sub || stringsHasSubstring(s, sub))
|
||||
}
|
||||
|
||||
func stringsHasSubstring(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Quiet "imported and not used" when the harness iterates the
|
||||
// fingerprint file directly without going through DriftDetector.
|
||||
var _ = os.Stat
|
||||
@@ -0,0 +1,211 @@
|
||||
package quality
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// ReplayQuery is one (query, expected) pair sourced from a query
|
||||
// log. The expected list is optional — when present the replay
|
||||
// computes recall@k against it; when absent the replay only
|
||||
// produces ranking-delta metrics (Kendall τ, top-k churn) between
|
||||
// baseline and candidate.
|
||||
type ReplayQuery struct {
|
||||
Query string `json:"query"`
|
||||
Expected []string `json:"expected,omitempty"`
|
||||
}
|
||||
|
||||
// RankerFunc is the contract a candidate / baseline implementation
|
||||
// satisfies. Returns the ordered list of result IDs (file paths /
|
||||
// symbol IDs — what shape the caller uses) for one query.
|
||||
type RankerFunc func(query string, topK int) []string
|
||||
|
||||
// PerQueryDelta is one query's outcome from a replay run.
|
||||
type PerQueryDelta struct {
|
||||
Query string `json:"query"`
|
||||
Baseline []string `json:"baseline"`
|
||||
Candidate []string `json:"candidate"`
|
||||
Kendall float64 `json:"kendall_tau"` // -1..+1; 1 = identical order
|
||||
Top1Changed bool `json:"top1_changed"`
|
||||
Top5Changes int `json:"top5_changes"` // |baseline[:5] △ candidate[:5]|
|
||||
RecallBase float64 `json:"recall_baseline,omitempty"`
|
||||
RecallCand float64 `json:"recall_candidate,omitempty"`
|
||||
}
|
||||
|
||||
// ReplayResult is the aggregate output of a replay run.
|
||||
type ReplayResult struct {
|
||||
PerQuery []PerQueryDelta `json:"per_query"`
|
||||
MeanKendall float64 `json:"mean_kendall_tau"`
|
||||
Top1ChurnPct float64 `json:"top1_churn_pct"` // % of queries with top1 changed
|
||||
MeanTop5Churn float64 `json:"mean_top5_changes"`
|
||||
RecallDelta float64 `json:"recall_delta"` // candidate - baseline mean recall (when ground truth exists)
|
||||
}
|
||||
|
||||
// Replay walks the query log, scores each query against baseline +
|
||||
// candidate, and aggregates the deltas. K is the top-K depth for
|
||||
// the comparison (10 is the standard NDCG@10 / top-10 churn shape).
|
||||
func Replay(queries []ReplayQuery, baseline, candidate RankerFunc, k int) (ReplayResult, error) {
|
||||
if baseline == nil || candidate == nil {
|
||||
return ReplayResult{}, fmt.Errorf("baseline and candidate rankers are required")
|
||||
}
|
||||
if k <= 0 {
|
||||
k = 10
|
||||
}
|
||||
out := ReplayResult{PerQuery: make([]PerQueryDelta, 0, len(queries))}
|
||||
var totKendall, totTop5 float64
|
||||
var top1Changes, withGT int
|
||||
var totRecallBase, totRecallCand float64
|
||||
for _, q := range queries {
|
||||
b := baseline(q.Query, k)
|
||||
c := candidate(q.Query, k)
|
||||
row := PerQueryDelta{
|
||||
Query: q.Query,
|
||||
Baseline: truncate(b, k),
|
||||
Candidate: truncate(c, k),
|
||||
Kendall: kendallTauTopK(b, c, k),
|
||||
Top1Changed: top1Differs(b, c),
|
||||
Top5Changes: setSymDiffSize(prefix(b, 5), prefix(c, 5)),
|
||||
}
|
||||
if len(q.Expected) > 0 {
|
||||
row.RecallBase = recallAtK(b, q.Expected, k)
|
||||
row.RecallCand = recallAtK(c, q.Expected, k)
|
||||
totRecallBase += row.RecallBase
|
||||
totRecallCand += row.RecallCand
|
||||
withGT++
|
||||
}
|
||||
totKendall += row.Kendall
|
||||
totTop5 += float64(row.Top5Changes)
|
||||
if row.Top1Changed {
|
||||
top1Changes++
|
||||
}
|
||||
out.PerQuery = append(out.PerQuery, row)
|
||||
}
|
||||
if n := len(queries); n > 0 {
|
||||
out.MeanKendall = totKendall / float64(n)
|
||||
out.MeanTop5Churn = totTop5 / float64(n)
|
||||
out.Top1ChurnPct = float64(top1Changes) / float64(n) * 100.0
|
||||
}
|
||||
if withGT > 0 {
|
||||
out.RecallDelta = (totRecallCand - totRecallBase) / float64(withGT)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// --- ranking-delta math ---------------------------------------------
|
||||
|
||||
// kendallTauTopK computes Kendall's τ over the top-K shared
|
||||
// elements of two ranked lists. Returns 1 when both lists are
|
||||
// empty or share fewer than 2 elements (τ is undefined; treat as
|
||||
// "no measurable disagreement").
|
||||
func kendallTauTopK(a, b []string, k int) float64 {
|
||||
aPref := prefix(a, k)
|
||||
bPref := prefix(b, k)
|
||||
rankA := indexMap(aPref)
|
||||
rankB := indexMap(bPref)
|
||||
// Intersection of the two prefixes.
|
||||
var shared []string
|
||||
for _, id := range aPref {
|
||||
if _, ok := rankB[id]; ok {
|
||||
shared = append(shared, id)
|
||||
}
|
||||
}
|
||||
n := len(shared)
|
||||
if n < 2 {
|
||||
return 1
|
||||
}
|
||||
var concordant, discordant int
|
||||
for i := range n {
|
||||
for j := i + 1; j < n; j++ {
|
||||
a, b := shared[i], shared[j]
|
||||
aOrder := rankA[a] < rankA[b]
|
||||
bOrder := rankB[a] < rankB[b]
|
||||
if aOrder == bOrder {
|
||||
concordant++
|
||||
} else {
|
||||
discordant++
|
||||
}
|
||||
}
|
||||
}
|
||||
pairs := concordant + discordant
|
||||
if pairs == 0 {
|
||||
return 1
|
||||
}
|
||||
return float64(concordant-discordant) / float64(pairs)
|
||||
}
|
||||
|
||||
func top1Differs(a, b []string) bool {
|
||||
if len(a) == 0 && len(b) == 0 {
|
||||
return false
|
||||
}
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return true
|
||||
}
|
||||
return a[0] != b[0]
|
||||
}
|
||||
|
||||
func setSymDiffSize(a, b []string) int {
|
||||
as := map[string]struct{}{}
|
||||
for _, x := range a {
|
||||
as[x] = struct{}{}
|
||||
}
|
||||
bs := map[string]struct{}{}
|
||||
for _, x := range b {
|
||||
bs[x] = struct{}{}
|
||||
}
|
||||
count := 0
|
||||
for x := range as {
|
||||
if _, ok := bs[x]; !ok {
|
||||
count++
|
||||
}
|
||||
}
|
||||
for x := range bs {
|
||||
if _, ok := as[x]; !ok {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func recallAtK(returned, expected []string, k int) float64 {
|
||||
if len(expected) == 0 {
|
||||
return 0
|
||||
}
|
||||
expSet := map[string]bool{}
|
||||
for _, e := range expected {
|
||||
expSet[e] = true
|
||||
}
|
||||
hits := 0
|
||||
limit := min(k, len(returned))
|
||||
for i := range limit {
|
||||
if expSet[returned[i]] {
|
||||
hits++
|
||||
}
|
||||
}
|
||||
return float64(hits) / float64(len(expected))
|
||||
}
|
||||
|
||||
func prefix(s []string, k int) []string {
|
||||
if k <= 0 || len(s) <= k {
|
||||
return s
|
||||
}
|
||||
return s[:k]
|
||||
}
|
||||
|
||||
func truncate(s []string, k int) []string {
|
||||
out := make([]string, 0, k)
|
||||
out = append(out, prefix(s, k)...)
|
||||
return out
|
||||
}
|
||||
|
||||
func indexMap(s []string) map[string]int {
|
||||
m := make(map[string]int, len(s))
|
||||
for i, v := range s {
|
||||
if _, ok := m[v]; !ok {
|
||||
m[v] = i
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Used only to keep imports stable when iterating.
|
||||
var _ = sort.Sort
|
||||
@@ -0,0 +1,179 @@
|
||||
package quality
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Test rankers: stub ranker funcs that return fixed lists per query
|
||||
// — keeps the replay tests deterministic and avoids pulling in the
|
||||
// real engine.
|
||||
|
||||
func staticRanker(byQuery map[string][]string) RankerFunc {
|
||||
return func(q string, topK int) []string {
|
||||
out := byQuery[q]
|
||||
if topK > 0 && len(out) > topK {
|
||||
return out[:topK]
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplay_IdenticalRankersZeroChurn(t *testing.T) {
|
||||
baseline := staticRanker(map[string][]string{"q1": {"a", "b", "c"}})
|
||||
candidate := baseline
|
||||
|
||||
queries := []ReplayQuery{{Query: "q1"}}
|
||||
got, err := Replay(queries, baseline, candidate, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Top1ChurnPct != 0 {
|
||||
t.Errorf("identical rankers should have 0%% top1 churn, got %.2f%%", got.Top1ChurnPct)
|
||||
}
|
||||
if got.MeanKendall < 0.99 {
|
||||
t.Errorf("identical rankers should have kendall ≈ 1, got %.4f", got.MeanKendall)
|
||||
}
|
||||
if got.MeanTop5Churn != 0 {
|
||||
t.Errorf("identical rankers should have 0 top5 changes, got %.2f", got.MeanTop5Churn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplay_OppositeRankersHighChurn(t *testing.T) {
|
||||
baseline := staticRanker(map[string][]string{"q1": {"a", "b", "c", "d", "e"}})
|
||||
candidate := staticRanker(map[string][]string{"q1": {"e", "d", "c", "b", "a"}})
|
||||
|
||||
queries := []ReplayQuery{{Query: "q1"}}
|
||||
got, err := Replay(queries, baseline, candidate, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Top1ChurnPct != 100 {
|
||||
t.Errorf("reversed ranker should have 100%% top1 churn, got %.2f%%", got.Top1ChurnPct)
|
||||
}
|
||||
// Kendall τ for reversed order over 5 elements: all pairs are
|
||||
// discordant → τ = -1.
|
||||
if got.MeanKendall > -0.99 {
|
||||
t.Errorf("reversed rankers should have kendall ≈ -1, got %.4f", got.MeanKendall)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplay_RecallDelta(t *testing.T) {
|
||||
baseline := staticRanker(map[string][]string{"q1": {"miss1", "miss2", "a"}})
|
||||
candidate := staticRanker(map[string][]string{"q1": {"a", "miss1", "miss2"}})
|
||||
|
||||
queries := []ReplayQuery{
|
||||
{Query: "q1", Expected: []string{"a"}},
|
||||
}
|
||||
got, err := Replay(queries, baseline, candidate, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Both find "a" within top 10 → both recall = 1.0 → delta = 0.
|
||||
if math.Abs(got.RecallDelta) > 0.01 {
|
||||
t.Errorf("both-find recall delta = %.4f, want 0", got.RecallDelta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplay_RecallDelta_CandidateBetter(t *testing.T) {
|
||||
// Baseline finds nothing in top-K, candidate finds the expected.
|
||||
baseline := staticRanker(map[string][]string{"q1": {"x", "y", "z"}})
|
||||
candidate := staticRanker(map[string][]string{"q1": {"a"}})
|
||||
|
||||
queries := []ReplayQuery{{Query: "q1", Expected: []string{"a"}}}
|
||||
got, _ := Replay(queries, baseline, candidate, 10)
|
||||
if got.RecallDelta <= 0 {
|
||||
t.Errorf("candidate-better should yield positive recall delta, got %.4f", got.RecallDelta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplay_NilRankerErrors(t *testing.T) {
|
||||
_, err := Replay(nil, nil, staticRanker(nil), 10)
|
||||
if err == nil {
|
||||
t.Error("nil baseline should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKendallTauTopK_PerfectMatch(t *testing.T) {
|
||||
tau := kendallTauTopK([]string{"a", "b", "c"}, []string{"a", "b", "c"}, 10)
|
||||
if tau != 1 {
|
||||
t.Errorf("identical lists τ = %.4f, want 1", tau)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKendallTauTopK_FullyReversed(t *testing.T) {
|
||||
tau := kendallTauTopK([]string{"a", "b", "c"}, []string{"c", "b", "a"}, 10)
|
||||
if tau != -1 {
|
||||
t.Errorf("reversed lists τ = %.4f, want -1", tau)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKendallTauTopK_PartialOverlap(t *testing.T) {
|
||||
// Only "b" and "c" overlap; in baseline they're in order (b,c);
|
||||
// in candidate they're reversed (c,b). Single discordant pair
|
||||
// out of 1 total → τ = -1.
|
||||
tau := kendallTauTopK(
|
||||
[]string{"a", "b", "c", "d"},
|
||||
[]string{"e", "c", "b", "f"},
|
||||
10,
|
||||
)
|
||||
if tau != -1 {
|
||||
t.Errorf("partial-overlap τ = %.4f, want -1", tau)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKendallTauTopK_TooFewSharedReturnsOne(t *testing.T) {
|
||||
// Less than 2 shared elements → τ undefined; we return 1 to
|
||||
// mean "no measurable disagreement".
|
||||
tau := kendallTauTopK([]string{"a", "b"}, []string{"c", "d"}, 10)
|
||||
if tau != 1 {
|
||||
t.Errorf("disjoint lists τ = %.4f, want 1 (no measurable disagreement)", tau)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSymDiffSize(t *testing.T) {
|
||||
if got := setSymDiffSize([]string{"a", "b"}, []string{"b", "c"}); got != 2 {
|
||||
t.Errorf("symdiff = %d, want 2 (a and c are unique)", got)
|
||||
}
|
||||
if got := setSymDiffSize([]string{"a"}, []string{"a"}); got != 0 {
|
||||
t.Errorf("symdiff identical = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecallAtK_BoundaryCases(t *testing.T) {
|
||||
// No expected → 0.
|
||||
if got := recallAtK([]string{"a"}, nil, 10); got != 0 {
|
||||
t.Errorf("empty expected = %.4f, want 0", got)
|
||||
}
|
||||
// All expected found.
|
||||
if got := recallAtK([]string{"a", "b"}, []string{"a", "b"}, 10); got != 1 {
|
||||
t.Errorf("full recall = %.4f, want 1", got)
|
||||
}
|
||||
// Half found.
|
||||
if got := recallAtK([]string{"a"}, []string{"a", "b"}, 10); got != 0.5 {
|
||||
t.Errorf("half recall = %.4f, want 0.5", got)
|
||||
}
|
||||
// Cut by k.
|
||||
if got := recallAtK([]string{"x", "y", "a"}, []string{"a"}, 2); got != 0 {
|
||||
t.Errorf("k-cut recall = %.4f, want 0 (a is outside top-2)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTop1Differs(t *testing.T) {
|
||||
if !top1Differs([]string{"a"}, []string{"b"}) {
|
||||
t.Error("different top1 should report changed")
|
||||
}
|
||||
if top1Differs([]string{"a"}, []string{"a"}) {
|
||||
t.Error("same top1 should not report changed")
|
||||
}
|
||||
if !top1Differs([]string{}, []string{"a"}) {
|
||||
t.Error("empty vs non-empty top1 should report changed")
|
||||
}
|
||||
if top1Differs(nil, nil) {
|
||||
t.Error("both empty should not report changed")
|
||||
}
|
||||
}
|
||||
|
||||
// Quiet "imported and not used" if a future helper needs it.
|
||||
var _ = strings.HasPrefix
|
||||
@@ -0,0 +1,126 @@
|
||||
package quality
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SignalFeedback ties one rerank signal to its observed effect on
|
||||
// agent-confirmed-useful results. Source: the existing `feedback`
|
||||
// tool's per-symbol useful / not-needed / missing counters.
|
||||
type SignalFeedback struct {
|
||||
Signal string `json:"signal"`
|
||||
Weight float64 `json:"current_weight"`
|
||||
UsefulHits int `json:"useful_hits"`
|
||||
MissedHits int `json:"missed_hits"`
|
||||
// SuggestedWeight is the tuner's recommendation; the operator
|
||||
// decides whether to apply it. >Weight = the signal is producing
|
||||
// useful results and should be amplified; <Weight = it's
|
||||
// adding noise.
|
||||
SuggestedWeight float64 `json:"suggested_weight"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
}
|
||||
|
||||
// SuggestWeights consumes the per-signal feedback rows + a global
|
||||
// nudge factor and emits a per-signal suggestion. Pure analysis —
|
||||
// the rerank pipeline isn't mutated; the caller applies via
|
||||
// `.gortex.yaml::search.weights`.
|
||||
//
|
||||
// Algorithm:
|
||||
// - signals with useful_hits > missed_hits get nudged UP by
|
||||
// min(0.1, (useful - missed) / 100 * nudge)
|
||||
// - signals with missed_hits > useful_hits get nudged DOWN by
|
||||
// the same formula
|
||||
// - signals with neither (no data) keep their current weight and
|
||||
// report "insufficient data"
|
||||
//
|
||||
// Nudge is the per-call cap; 1.0 means "max ±0.1 per call",
|
||||
// 0.5 means "half of that". Keeping nudges small makes tuning a
|
||||
// gradient process rather than a wholesale weight swap.
|
||||
func SuggestWeights(rows []SignalFeedback, nudge float64) []SignalFeedback {
|
||||
if nudge <= 0 {
|
||||
nudge = 1.0
|
||||
}
|
||||
out := make([]SignalFeedback, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
delta := 0.0
|
||||
reason := ""
|
||||
switch {
|
||||
case r.UsefulHits == 0 && r.MissedHits == 0:
|
||||
reason = "insufficient data (no useful / missed records this window)"
|
||||
case r.UsefulHits > r.MissedHits:
|
||||
diff := float64(r.UsefulHits - r.MissedHits)
|
||||
delta = clamp(diff/100.0*nudge, 0, 0.1)
|
||||
reason = fmt.Sprintf("useful_hits=%d > missed=%d → nudge up by %.3f",
|
||||
r.UsefulHits, r.MissedHits, delta)
|
||||
case r.MissedHits > r.UsefulHits:
|
||||
diff := float64(r.MissedHits - r.UsefulHits)
|
||||
delta = -clamp(diff/100.0*nudge, 0, 0.1)
|
||||
reason = fmt.Sprintf("missed_hits=%d > useful=%d → nudge down by %.3f",
|
||||
r.MissedHits, r.UsefulHits, -delta)
|
||||
default:
|
||||
reason = fmt.Sprintf("useful_hits == missed_hits (%d) → no change", r.UsefulHits)
|
||||
}
|
||||
r.SuggestedWeight = r.Weight + delta
|
||||
if r.SuggestedWeight < 0 {
|
||||
r.SuggestedWeight = 0
|
||||
}
|
||||
r.Reasoning = reason
|
||||
out = append(out, r)
|
||||
}
|
||||
// Stable display order: biggest absolute suggested change first
|
||||
// so the operator sees the action items at the top.
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
return absVal(out[i].SuggestedWeight-out[i].Weight) > absVal(out[j].SuggestedWeight-out[j].Weight)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// RenderTuningMarkdown produces the operator-facing report. Columns:
|
||||
// signal, current weight, useful / missed counts, suggested weight,
|
||||
// reasoning. Tail summary: count of nudges with abs(delta) > 0.05
|
||||
// so the operator knows whether anything substantial is on the
|
||||
// table.
|
||||
func RenderTuningMarkdown(rows []SignalFeedback) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintln(&b, "# Rerank weight-tuning suggestion")
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, "_Each row's `suggested_weight` is a calculated nudge from the current `.gortex.yaml::search.weights` value, based on the feedback log. **Operator decides whether to apply** — no automatic mutation._")
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, "| signal | current | useful | missed | suggested | Δ | reasoning |")
|
||||
fmt.Fprintln(&b, "|--------|--------:|-------:|-------:|----------:|--:|-----------|")
|
||||
substantial := 0
|
||||
for _, r := range rows {
|
||||
delta := r.SuggestedWeight - r.Weight
|
||||
if absVal(delta) >= 0.05 {
|
||||
substantial++
|
||||
}
|
||||
fmt.Fprintf(&b, "| %s | %.3f | %d | %d | %.3f | %+.3f | %s |\n",
|
||||
r.Signal, r.Weight, r.UsefulHits, r.MissedHits,
|
||||
r.SuggestedWeight, delta, r.Reasoning)
|
||||
}
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintf(&b, "**Substantial nudges (|Δ| ≥ 0.05): %d / %d signals.**\n",
|
||||
substantial, len(rows))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// --- math helpers ---------------------------------------------------
|
||||
|
||||
func clamp(v, lo, hi float64) float64 {
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func absVal(v float64) float64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package quality
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSuggestWeights_UsefulPushesUp(t *testing.T) {
|
||||
in := []SignalFeedback{
|
||||
{Signal: "bm25", Weight: 1.0, UsefulHits: 80, MissedHits: 20},
|
||||
}
|
||||
out := SuggestWeights(in, 1.0)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("got %d rows, want 1", len(out))
|
||||
}
|
||||
if out[0].SuggestedWeight <= out[0].Weight {
|
||||
t.Errorf("useful > missed should push weight UP; got %.3f → %.3f", out[0].Weight, out[0].SuggestedWeight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestWeights_MissedPushesDown(t *testing.T) {
|
||||
in := []SignalFeedback{
|
||||
{Signal: "churn", Weight: 0.5, UsefulHits: 5, MissedHits: 50},
|
||||
}
|
||||
out := SuggestWeights(in, 1.0)
|
||||
if out[0].SuggestedWeight >= out[0].Weight {
|
||||
t.Errorf("missed > useful should push weight DOWN; got %.3f → %.3f", out[0].Weight, out[0].SuggestedWeight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestWeights_NoDataKeepsWeight(t *testing.T) {
|
||||
in := []SignalFeedback{
|
||||
{Signal: "minhash", Weight: 0.3, UsefulHits: 0, MissedHits: 0},
|
||||
}
|
||||
out := SuggestWeights(in, 1.0)
|
||||
if out[0].SuggestedWeight != out[0].Weight {
|
||||
t.Errorf("no-data should keep weight unchanged; got %.3f → %.3f", out[0].Weight, out[0].SuggestedWeight)
|
||||
}
|
||||
if !strings.Contains(out[0].Reasoning, "insufficient data") {
|
||||
t.Errorf("no-data reasoning should mention insufficient data; got %q", out[0].Reasoning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestWeights_TiePicksNoChange(t *testing.T) {
|
||||
in := []SignalFeedback{
|
||||
{Signal: "fan_in", Weight: 0.6, UsefulHits: 10, MissedHits: 10},
|
||||
}
|
||||
out := SuggestWeights(in, 1.0)
|
||||
if out[0].SuggestedWeight != out[0].Weight {
|
||||
t.Errorf("tie should keep weight; got %.3f → %.3f", out[0].Weight, out[0].SuggestedWeight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestWeights_NudgeCapped(t *testing.T) {
|
||||
// Even with absurdly high useful counts, the nudge per call is
|
||||
// capped at 0.1 so a single run can't wildly swing weights.
|
||||
in := []SignalFeedback{
|
||||
{Signal: "fan_in", Weight: 0.5, UsefulHits: 1_000_000, MissedHits: 0},
|
||||
}
|
||||
out := SuggestWeights(in, 1.0)
|
||||
delta := out[0].SuggestedWeight - out[0].Weight
|
||||
if delta > 0.101 {
|
||||
t.Errorf("nudge should be capped at 0.1, got %+.3f", delta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestWeights_DownNudgeCanNotGoNegative(t *testing.T) {
|
||||
in := []SignalFeedback{
|
||||
{Signal: "rare", Weight: 0.05, UsefulHits: 0, MissedHits: 1_000_000},
|
||||
}
|
||||
out := SuggestWeights(in, 1.0)
|
||||
if out[0].SuggestedWeight < 0 {
|
||||
t.Errorf("suggested weight should never go negative, got %.3f", out[0].SuggestedWeight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestWeights_SortedByAbsoluteDelta(t *testing.T) {
|
||||
in := []SignalFeedback{
|
||||
{Signal: "small_change", Weight: 1.0, UsefulHits: 5, MissedHits: 4},
|
||||
{Signal: "big_change", Weight: 1.0, UsefulHits: 90, MissedHits: 10},
|
||||
{Signal: "no_change", Weight: 1.0, UsefulHits: 0, MissedHits: 0},
|
||||
}
|
||||
out := SuggestWeights(in, 1.0)
|
||||
if out[0].Signal != "big_change" {
|
||||
t.Errorf("first row should be the biggest change; got %s", out[0].Signal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTuningMarkdown_HasHeaderAndRows(t *testing.T) {
|
||||
rows := []SignalFeedback{
|
||||
{Signal: "bm25", Weight: 1.0, UsefulHits: 80, MissedHits: 20, SuggestedWeight: 1.06, Reasoning: "useful > missed"},
|
||||
}
|
||||
md := RenderTuningMarkdown(rows)
|
||||
for _, want := range []string{
|
||||
"# Rerank weight-tuning suggestion",
|
||||
"| bm25 |",
|
||||
"1.000",
|
||||
"1.060",
|
||||
"useful > missed",
|
||||
"**Substantial nudges",
|
||||
} {
|
||||
if !strings.Contains(md, want) {
|
||||
t.Errorf("markdown missing %q\n----\n%s", want, md)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTuningMarkdown_SubstantialCount(t *testing.T) {
|
||||
rows := []SignalFeedback{
|
||||
{Signal: "tiny", Weight: 1.0, SuggestedWeight: 1.01}, // not substantial
|
||||
{Signal: "big1", Weight: 1.0, SuggestedWeight: 1.10}, // substantial
|
||||
{Signal: "big2", Weight: 1.0, SuggestedWeight: 0.90}, // substantial (negative)
|
||||
{Signal: "nothing", Weight: 1.0, SuggestedWeight: 1.0}, // not substantial
|
||||
}
|
||||
md := RenderTuningMarkdown(rows)
|
||||
if !strings.Contains(md, "Substantial nudges (|Δ| ≥ 0.05): 2 / 4") {
|
||||
t.Errorf("substantial count wrong:\n%s", md)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user